← All documentation
Topics

- Working with data
- Designer components
- Field controls
- Search & filtering
- Calendars
- PDF & printing
- Email
- SMS
- Notifications
- Security
- Helper functions
- JavaScript API
- Performance

# 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
- 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.

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:

- `esc(value)` — makes text safe for HTML (turns `<` into `&lt;`). Always wrap user text in `esc()` 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 a `bool`/`range` before the user touches it.
- `when: 'otherKey'` — only show this field when `otherKey` is set. Or `when: { key:'centerType', eq:'image' }` to show it only when another field equals a value.
- `rerender: true` — on a `select` or `bool`, 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.

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

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.