PPPHP

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.

Who is this for? Owners and administrators. This guide is hidden from regular users.

Contents

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:

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.

FileLanguageIts job
registry.jsJavaScriptAdds the block to the palette & draws its canvas preview + its properties panel.
ComponentRenderer.phpPHPDraws the real output in the published app.
designer.cssCSSThe styling, shared by both the canvas and the built app.
Two hidden gates you must not forget:
  1. handles() in ComponentRenderer.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).
  2. 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-list

Tip: 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:

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:

typeWhat the user seesExample
textOne-line text box{ key:'title', label:'Title', type:'text' }
textareaMulti-line text box{ key:'body', label:'Body', type:'textarea' }
numberNumber box{ key:'count', label:'Count', type:'number' }
boolCheckbox (on/off){ key:'showIcon', label:'Show icon', type:'bool', def:true }
selectDropdown{ key:'size', label:'Size', type:'select', options:['sm','md','lg'] }
colorColour picker + hex box{ key:'bg', label:'Background', type:'color' }
rangeSlider with a live read-out{ key:'gap', label:'Gap', type:'range', min:0, max:60, step:2, suffix:'px', def:12 }
iconIcon picker{ key:'icon', label:'Icon', type:'icon' }
csvComma-separated list{ key:'tags', label:'Tags (csv)', type:'csv' }
dbtableDropdown of the project’s tables{ key:'table', label:'Table', type:'dbtable' }
dbcolumnDropdown of a chosen table’s columns{ key:'col', label:'Column', type:'dbcolumn', tableKey:'table' }

Handy extras you can add to any field object:

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.

Do not forget the whitelist! In the same file, find the 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); }
Make the CSS ship. The built app only includes CSS whose class prefix is on an allow-list. Open 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-textBrand colour & text on it
--pp-text / --pp-mutedBody text & subtle text
--pp-surface / --pp-surface-softCard / soft background
--pp-borderBorder colour
--pp-success / --pp-warning / --pp-danger / --pp-infoStatus colours
--pp-radius / --pp-radius-lgCorner 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:

// 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

  1. Hard-refresh the Designer (Cmd/Ctrl + Shift + R) so the new registry.js loads. Your block should appear in the palette and preview on the canvas.
  2. Build the project and open the published page. The output must look the same as the canvas.
  3. If it shows on the canvas but disappears when published → you missed the handles() entry.
  4. If it appears but looks unstyled → you missed the CSS allow-list, or your class prefix does not match.
  5. 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.
Rule of thumb: copy an existing component that is close to what you want (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.