Webcomponents and CakePHP FormHelper

Webcomponents are starting to get more traction now that they are fully supported across browsers. I have recently been rebuilding my personal todo list software Docket with HTMX and Webcomponents. While this won’t be an introduction to webcomponents — I found this article and this one to be helpful in learning how to build webcomponents. For this article, I wanted to share how webcomponents can be integrated with CakePHP’s FormHelper.

Replacing React libraries with Web components

My need for this came up while replacing react-select components during the htmx conversion. I wanted to retain the UX improvements that react-select offered like rich item option content, and autocomplete filtering. However, I didn’t want to pull in many dependencies. This excluded jQuery based libraries, and a few other ‘vanilla JS’ projects. I found several webcomponent libraries that looked good like shoelace. However, I was drawn to learning web components from the ‘ground floor’ so I could better understand the sharp corners that these libraries were solving in addition to providing consistent design and UX.

After prototyping the webcomponent logic, I wanted to integrate the necessary markup with the templates that FormHelper uses. I wanted to see how well webcomponents paired with the Widget extensions that FormHelper has.

The select-box component HTML

The select-box webcomponent can be found on GitHub . The HTML for this component looks like:

Show Plain Text
  1. <select-box name="" type="projectpicker" id="project-id" val="1" tabindex="-1">
  2.     <input type="text" name="project_id" style="display:none">
  3.     <select-box-current selectedhtml="" open="false">
  4.         <span class="select-box-value">Home</span>
  5.         <input type="text" class="select-box-input">
  6.     </select-box-current>
  7.     <select-box-menu val="1" style="display: none;" filter="" current="0">
  8.         <select-box-option value="1" selected="true" aria-selected="true" aria-current="true">
  9.             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
  10.                 <path fill="#218fa7" d="M8 4a4 4 0 1 1 0 8a4 4 0 0 1 0-8Z"></path>
  11.             </svg>
  12.             Home
  13.         </select-box-option>
  14.         <select-box-option value="2" aria-selected="false">
  15.             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
  16.                 <path fill="#b86fd1" d="M8 4a4 4 0 1 1 0 8a4 4 0 0 1 0-8Z"></path>
  17.             </svg>
  18.             Work
  19.         </select-box-option>
  20.     </select-box-menu>
  21. </select-box>

With the HTML roughed in and working on the client side I wanted to wire up this UX component to FormHelper with the goal of being able to create inputs with a small amount of PHP code:

Show Plain Text
  1. echo $this->Form->control('project_id', ['type' => 'projectpicker', 'projects' => $projects]);

Widget Class

CakePHP’s FormHelper provides extension points that enable you to integrate custom input widget logic and markup. We start off by implementing the Cake\View\Widget\WidgetInterface. The widget class for my project input looks like:

Show Plain Text
  1. <?php
  2. declare(strict_types=1);
  3.  
  4. namespace App\View\Widget;
  5.  
  6. use Cake\View\Form\ContextInterface;
  7. use Cake\View\StringTemplate;
  8. use Cake\View\View;
  9. use Cake\View\Widget\BasicWidget;
  10. use RuntimeException;
  11.  
  12. class ProjectPickerWidget extends BasicWidget
  13. {
  14.     /**
  15.      * Data defaults.
  16.      *
  17.      * @var array<string, mixed>
  18.      */
  19.     protected $defaults = [
  20.         'name' => '',
  21.         'disabled' => null,
  22.         'val' => null,
  23.         'projects' => [],
  24.         'tabindex' => '-1',
  25.         'templateVars' => [],
  26.         'inputAttrs' => [],
  27.     ];
  28.  
  29.     public function __construct(private StringTemplate $templates, private View $view)
  30.     {
  31.     }
  32.  
  33.     public function render(array $data, ContextInterface $context): string
  34.     {
  35.         $data = $this->mergeDefaults($data, $context);
  36.         if (empty($data['projects'])) {
  37.             throw new RuntimeException('`projects` option is required');
  38.         }
  39.         $selected = $data['val'] ?? null;
  40.         $projects = $data['projects'];
  41.         $inputAttrs = $data['inputAttrs'] ?? [];
  42.         unset(
  43.             $data['projects'],
  44.             $data['data-validity-message'],
  45.             $data['oninvalid'],
  46.             $data['oninput'],
  47.             $data['inputAttrs']
  48.         );
  49.  
  50.         $inputAttrs += ['style' => 'display:none'];
  51.  
  52.         $options = [];
  53.         foreach ($projects as $project) {
  54.             // Generate the option body
  55.             $optionBody = $this->view->element('icons/dot16', ['color' => $project->color_hex]) . h($project->name);
  56.             $optAttrs = [
  57.                 'selected' => $project->id == $selected,
  58.             ];
  59.  
  60.             $options[] = $this->templates->format('select-box-option', [
  61.                 'value' => $project->id,
  62.                 'text' => $optionBody,
  63.                 'attrs' => $this->templates->formatAttributes($optAttrs, ['text', 'value']),
  64.             ]);
  65.         }
  66.  
  67.         // Include a 'hidden' text input that is manipulated by the webcomponent.
  68.         $hidden = $this->templates->format('input', [
  69.             'name' => $data['name'],
  70.             'value' => $selected,
  71.             'type' => 'text',
  72.             'attrs' => $this->templates->formatAttributes($inputAttrs),
  73.         ]);
  74.         $attrs = $this->templates->formatAttributes($data);
  75.  
  76.         return $this->templates->format('select-box', [
  77.             'templateVars' => $data['templateVars'],
  78.             'attrs' => $attrs,
  79.             'hidden' => $hidden,
  80.             'options' => implode('', $options),
  81.         ]);
  82.     }
  83.  
  84.     public function secureFields(array $data): array
  85.     {
  86.         return [$data['name']];
  87.     }
  88. }

Next, we add the widget to FormHelper in our AppController::beforeRender:

Show Plain Text
  1. <?php
  2. $this->viewBuilder()
  3.     ->addHelper('Form', [
  4.         'templates' => 'formtemplates',
  5.         'widgets' => [
  6.             'projectpicker' => [ProjectPickerWidget::class, '_view'],
  7.         ],
  8.     ]);

Finally, because I wanted to separate the markup from the Widget logic, I’m using custom templates. The templates for my project picker look like:

Show Plain Text
  1. <?php
  2. return [
  3.     'select-box-option' => '<select-box-option value="{{value}}"{{attrs}}>{{text}}</select-box-option>',
  4.     'select-box' => <<<HTML
  5.     <select-box name="{{name}}"{{attrs}}>
  6.         {{hidden}}
  7.         <select-box-current>
  8.             <span class="select-box-value"></span>
  9.             <input type="text" class="select-box-input" />
  10.         </select-box-current>
  11.         <select-box-menu>{{options}}</select-box-menu>
  12.     </select-box>
  13.     HTML,
  14. ];

With all of this done, we can make custom inputs wrapped in the standard HTML that FormHelper creates. I think this could be increasingly valuable as folks discover the power and simplicity that webcomponents offer.

Comments

Makes me wanna dig more into HTMX and web components ! Thanks that was very interesting

Max on 1/5/24

Have your say: