Building Designer Components
A step-by-step guide to creating your own drag-and-drop blocks for the visual Designer — the same way the built-in Menu Card, Clock and KPI Tile were made. Written for beginners: if you can copy, paste and change a few words, you can add a component.
Contents
- The big idea — what a component is
- The golden rule (WYSIWYG) & the three files
- Where the files live
- Step 1 — Register the component (JavaScript)
- The properties panel — field types you can use
- Step 2 — Render it in the built app (PHP)
- Step 3 — Style it (CSS) and make it ship
- The theme rule — always use colour tokens
- Full worked example: a “Callout” component
- Advanced: components that show live data
- Advanced: container components (that hold others)
- Test & verify your component
- Checklist & common mistakes
The big idea — what a component is
A component is a building block you can drag from the left palette onto the canvas — a heading, a card, a chart, a clock. Each component has:
- Properties (also called props) — the settings a user changes in the right-hand panel (text, colour, size…).
- A preview that shows on the design canvas.
- A final output that appears in the generated (published) app.
The golden rule (WYSIWYG) & the three files
WYSIWYG means “What You See Is What You Get”. Whatever the user designs on the canvas must look the same in the published app. Because of this, a component is written in two languages that must agree: JavaScript for the canvas, and PHP for the built app.
| File | Language | Its job |
|---|---|---|
registry.js | JavaScript | Adds the block to the palette & draws its canvas preview + its properties panel. |
ComponentRenderer.php | PHP | Draws the real output in the published app. |
designer.css | CSS | The styling, shared by both the canvas and the built app. |
handles()inComponentRenderer.php— a whitelist. If your component type is not listed there, it is silently dropped from the built app (it will show on the canvas but vanish when published).- The CSS allow-list in
BuildWriter.php— if your CSS class prefix is not listed, your component ships unstyled.
Where the files live
modules/Designer/Views/app/registry.js ← 1. register + canvas preview + properties
core/Build/ComponentRenderer.php ← 2. render() case + handles() whitelist
modules/Designer/Views/app/designer.css ← 3. styling (theme-aware)
core/Build/BuildWriter.php ← extractComponentCss() CSS allow-listTip: search each file for an existing simple component such as 'callout',
'alert' or 'heading' and copy its shape.
Step 1 — Register the component (JavaScript)
Open registry.js and add a def(…) call. def means
“define a component”. Here is the skeleton, with every part explained:
def('callout', { // 1. the TYPE: a unique id, lower-case with hyphens
label: 'Callout', // 2. name shown in the palette
icon: 'bi-megaphone', // 3. a Bootstrap icon name (https://icons.getbootstrap.com)
category: 'Content', // 4. which palette group it appears under
// 5. the STARTING values for the properties (props) when first dropped
defaults: function () {
return { text: 'Heads up!', tone: 'info' };
},
// 6. the CANVAS PREVIEW — return an HTML string. `n` is the node, n.props are the settings.
render: function (n) {
var p = n.props;
return '<div class="pp-callout pp-callout-' + esc(p.tone || 'info') + '">'
+ esc(p.text || '') + '</div>';
},
// 7. the PROPERTIES PANEL — a list of groups, each with fields the user can edit
schema: function () {
return [
generalGroup([
{ key: 'text', label: 'Message', type: 'textarea' },
{ key: 'tone', label: 'Tone', type: 'select',
options: ['info', 'success', 'warning', 'danger'] }
]),
styleGroup()
];
}
});
Three helpers you will use constantly:
esc(value)— makes text safe for HTML (turns<into<). Always wrap user text inesc()to prevent broken layouts and security holes.generalGroup([…])— the top “General” panel section. It automatically adds a Name and Visible field around yours.styleGroup()— a ready-made “Style” section (colours, padding, margins, border, shadow, custom CSS). Add it and users get all of that for free.
The properties panel — field types you can use
Inside a schema group, each field is an object with a key (the prop name),
a label, and a type. These are the available types:
| type | What the user sees | Example |
|---|---|---|
text | One-line text box | { key:'title', label:'Title', type:'text' } |
textarea | Multi-line text box | { key:'body', label:'Body', type:'textarea' } |
number | Number box | { key:'count', label:'Count', type:'number' } |
bool | Checkbox (on/off) | { key:'showIcon', label:'Show icon', type:'bool', def:true } |
select | Dropdown | { key:'size', label:'Size', type:'select', options:['sm','md','lg'] } |
color | Colour picker + hex box | { key:'bg', label:'Background', type:'color' } |
range | Slider with a live read-out | { key:'gap', label:'Gap', type:'range', min:0, max:60, step:2, suffix:'px', def:12 } |
icon | Icon picker | { key:'icon', label:'Icon', type:'icon' } |
csv | Comma-separated list | { key:'tags', label:'Tags (csv)', type:'csv' } |
dbtable | Dropdown of the project’s tables | { key:'table', label:'Table', type:'dbtable' } |
dbcolumn | Dropdown of a chosen table’s columns | { key:'col', label:'Column', type:'dbcolumn', tableKey:'table' } |
Handy extras you can add to any field object:
placeholder: 'e.g. 12px'— hint text inside the box.def: true— the default shown for abool/rangebefore the user touches it.when: 'otherKey'— only show this field whenotherKeyis set. Orwhen: { key:'centerType', eq:'image' }to show it only when another field equals a value.rerender: true— on aselectorbool, rebuild the panel when it changes (used to reveal/hide other fields live).
Step 2 — Render it in the built app (PHP)
Now teach the publisher how to draw your component. Open
core/Build/ComponentRenderer.php and add a case inside the big
render() switch. It should produce the same HTML as your canvas
preview.
case 'callout':
// $p holds the props (settings). Always validate/escape.
$tone = in_array($p['tone'] ?? 'info', ['info', 'success', 'warning', 'danger'], true)
? (string) $p['tone'] : 'info';
return '<div class="pp-callout pp-callout-' . $tone . '">'
. self::e((string) ($p['text'] ?? '')) . '</div>';
self::e(…) is the PHP twin of esc() — it is
htmlspecialchars(). Use it for every piece of user text.
handles() method and add your type to the list:
$set = array_flip([
'card', 'menu-card', 'callout', // ← add 'callout' here
'divider', 'alert', 'heading', ...
]); Without this, your component renders on the canvas but is silently removed from
the published app.
Step 3 — Style it (CSS) and make it ship
Add your styles to modules/Designer/Views/app/designer.css. This one file styles
both the canvas and the built app, so there is only one place to edit.
.pp-callout { padding: 12px 16px; border-radius: 12px; font-weight: 600;
border: 1px solid var(--pp-border, #e2e8f0); background: var(--pp-surface, #fff);
color: var(--pp-text, #0f172a); }
.pp-callout-success { background: color-mix(in srgb, var(--pp-success, #16a34a) 12%, transparent); }
.pp-callout-warning { background: color-mix(in srgb, var(--pp-warning, #d97706) 12%, transparent); }
.pp-callout-danger { background: color-mix(in srgb, var(--pp-danger, #dc2626) 12%, transparent); }
core/Build/BuildWriter.php, find
extractComponentCss() and add your prefix to the $allow array:
$allow = ['.pp-hero', '.pp-menu-page', '.pp-mc', '.pp-callout', /* … */]; Any selector that contains .pp-callout will then be shipped.
The theme rule — always use colour tokens
PPPHP apps can be re-themed instantly. For your component to follow the theme, never hard-code colours. Instead use a token with a fallback:
/* GOOD — follows the theme, falls back to the hex if the token is missing */
color: var(--pp-primary, #4f46e5);
background: color-mix(in srgb, var(--pp-primary, #4f46e5) 12%, transparent);
/* BAD — frozen colour, ignores the theme */
color: #4f46e5;The most useful tokens:
--pp-primary / --pp-primary-text | Brand colour & text on it |
--pp-text / --pp-muted | Body text & subtle text |
--pp-surface / --pp-surface-soft | Card / soft background |
--pp-border | Border colour |
--pp-success / --pp-warning / --pp-danger / --pp-info | Status colours |
--pp-radius / --pp-radius-lg | Corner rounding |
Full worked example — the “Callout” component
Putting all three steps together, here is the complete recipe.
1. registry.js
def('callout', {
label: 'Callout', icon: 'bi-megaphone', category: 'Content',
defaults: function () { return { text: 'Heads up!', tone: 'info' }; },
render: function (n) {
var p = n.props;
return '<div class="pp-callout pp-callout-' + esc(p.tone || 'info') + '">'
+ esc(p.text || '') + '</div>';
},
schema: function () { return [
generalGroup([
{ key: 'text', label: 'Message', type: 'textarea' },
{ key: 'tone', label: 'Tone', type: 'select', options: ['info','success','warning','danger'] }
]),
styleGroup()
]; }
});
2. ComponentRenderer.php — the case + the handles() entry
case 'callout':
$tone = in_array($p['tone'] ?? 'info', ['info','success','warning','danger'], true)
? (string) $p['tone'] : 'info';
return '<div class="pp-callout pp-callout-' . $tone . '">'
. self::e((string) ($p['text'] ?? '')) . '</div>';
// …and in handles(): 'card', 'menu-card', 'callout', …
3. designer.css + the allow-list
.pp-callout { padding:12px 16px; border-radius:var(--pp-radius-lg,12px); font-weight:600;
border:1px solid var(--pp-border,#e2e8f0); background:var(--pp-surface,#fff); color:var(--pp-text,#0f172a); }
.pp-callout-success { background: color-mix(in srgb, var(--pp-success,#16a34a) 12%, transparent); }
/* + add '.pp-callout' to $allow in BuildWriter::extractComponentCss() */
Rebuild the project and your Callout appears in the palette, previews on the canvas, and renders identically in the published app. That is the whole pattern. 🎉
Advanced — components that show live data
A component can read from the project’s database. Because the canvas has no database, the
canvas preview shows a placeholder, and the PHP side emits a little
<?php … ?> block that queries the data when the page loads. This is
exactly how the KPI tiles, charts and the data-bound dashboard widgets work.
Two safety rules when building the SQL string in PHP:
- Sanitise table/column names — strip anything that is not a letter,
number or underscore:
preg_replace('/[^A-Za-z0-9_]/', '', $name). - Escape output with
htmlspecialchars().
// PHP side (ComponentRenderer): emit a runtime query block
$t = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['table'] ?? ''));
$col = preg_replace('/[^A-Za-z0-9_]/', '', (string) ($p['column'] ?? ''));
if ($t === '') return '<div class="pp-muted">Bind this widget to a table</div>';
return '<div class="pp-count"><?php '
. '$__n = (int) \PpPhp\Core\DB::FetchOne("SELECT COUNT(`' . $col . '`) FROM `' . $t . '`"); '
. 'echo htmlspecialchars((string) $__n); ?></div>';
In registry.js, give the user pickers with type:'dbtable' and
type:'dbcolumn' (see the field-type table above), and make the canvas preview show a
sample number or the table name.
Advanced — container components (that hold others)
A container (like Section, Group Container or Menu Group) can accept
other components dropped inside it. Set container: true and render a child zone.
// registry.js — canvas side
def('panel', {
label: 'Panel', icon: 'bi-window', category: 'Layout', container: true,
defaults: function () { return { heading: 'Panel' }; },
render: function (n, ctx) { // note the 2nd argument: ctx
return '<div class="pp-panel"><div class="pp-panel-h">' + esc(n.props.heading) + '</div>'
+ ctx.renderChildren(n, 'pp-panel-body pp-zone12 gap-normal') + '</div>';
},
schema: function () { return [ generalGroup([{ key:'heading', label:'Heading', type:'text' }]), styleGroup() ]; }
});
// ComponentRenderer.php — PHP side. $renderChildren draws the children.
case 'panel':
return '<div class="pp-panel"><div class="pp-panel-h">' . self::e((string) ($p['heading'] ?? 'Panel')) . '</div>'
. '<div class="pp-panel-body pp-zone12 gap-normal">' . $renderChildren($c) . '</div></div>';
// (remember to add 'panel' to handles())Adding the class pp-zone12 to the body makes it a 12-column grid, so children obey
their own Size & Layout width (e.g. a child set to 3/12 = four per row).
Test & verify your component
- Hard-refresh the Designer (Cmd/Ctrl + Shift +
R) so the new
registry.jsloads. Your block should appear in the palette and preview on the canvas. - Build the project and open the published page. The output must look the same as the canvas.
- If it shows on the canvas but disappears when published → you missed the
handles()entry. - If it appears but looks unstyled → you missed the CSS allow-list, or your class prefix does not match.
- If colours ignore the theme → you hard-coded a hex instead of a
var(--pp-*)token.
Checklist & common mistakes
| Done? | Step |
|---|---|
| ☐ | def('type', …) added to registry.js with defaults, render, schema. |
| ☐ | case 'type': added to ComponentRenderer::render() — same HTML as the canvas. |
| ☐ | 'type' added to ComponentRenderer::handles(). (most-forgotten step) |
| ☐ | CSS added to designer.css using var(--pp-*) tokens. |
| ☐ | Class prefix added to $allow in BuildWriter::extractComponentCss(). |
| ☐ | All user text wrapped in esc() (JS) and self::e() (PHP). |
| ☐ | Canvas preview and PHP output produce the same HTML. |
alert, menu-card, kpi-stat), rename it, and change the
parts you need. Every built-in component follows exactly the pattern above.
See also: the Field Plugins guide for building view / edit / filter controls for individual table fields.