PAD + web components

A field of a form

<pad-stepper> is form-associated: ElementInternals gives the {form} its value, the post carries it like any field, and padValidate checks it on the server.

{form}padValidatepadPosted formAssociatedElementInternals
Live

How it is built

PAD, on the server the browser

{if $ordered}
  <div class="callout is-good"><p><strong>Ordered:</strong> {$qty} × size {$size} - the stepper's value came with the post like any field.</p></div>
{/if}

{form 'order', error, class='stack'}
  <label for="qty">Quantity</label>
  {stepper name='qty', value=$qty, min=1, max=12}
  {if $qtyError}<span class="error">{$qtyError}</span>{/if}

  {input 'size', label='Size - s, m or l', class='input', value=$size}

  <button>Order</button>
{/form}

<script type="module" src="{$padRoot}webcomponents/elements/stepper.js"></script>
<?php

  // An order: the quantity comes from <pad-stepper>, a form-associated custom element whose
  // value the {form} posts like any field; the size from an {input}. padValidate checks both
  // on the server - the stepper lets the visitor go to 12, the rule stops at 10.

  $errors  = [];
  $ordered = FALSE;

  if ( padPosted ( 'order' ) ) {
    $errors  = padValidate ( [ 'qty' => 'required|integer|min:1|max:10', 'size' => 'required|in:s,m,l' ],
                             NULL, [ 'qty.max' => 'At most :n at a time', 'qty' => 'The quantity is a number from 1 to 10' ] );
    $ordered = ! $errors;
  }

  $qty      = (int) padRequest ( 'qty', 2 );
  $size     = wcText ( 'size' );
  $qtyError = $errors ['qty'] ?? '';

?>
{parms name, value=1, min=1, max=10}
{set $stepperValue = {#value}}
<pad-stepper name="{#name}" id="{#name}" value="{#value}" min="{#min}" max="{#max}">
  {shadow css='www:elements/stepper.css', focus}
    <button type="button" part="less" aria-label="One less">−</button>
    <output part="value">{$stepperValue}</output>
    <button type="button" part="more" aria-label="One more">+</button>
  {/shadow}
</pad-stepper>
// <pad-stepper>: a form-associated custom element. ElementInternals hands the form its value,
// so a post of the {form} carries name=value like an <input> - and a reset puts it back.

class PadStepper extends HTMLElement {

  static formAssociated = true;

  constructor() {
    super();
    this.internals = this.attachInternals();
  }

  connectedCallback() {
    this.initial = this.value;
    const [less, more] = this.shadowRoot.querySelectorAll('button');
    less.addEventListener('click', () => this.step(-1));
    more.addEventListener('click', () => this.step(1));
    this.show();
  }

  get value() { return Number(this.getAttribute('value')) || 0; }
  get min() { return Number(this.getAttribute('min') ?? 0); }
  get max() { return Number(this.getAttribute('max') ?? 99); }

  step(by) {
    this.setAttribute('value', Math.min(this.max, Math.max(this.min, this.value + by)));
    this.show();
  }

  show() {
    this.shadowRoot.querySelector('output').textContent = this.value;
    this.internals.setFormValue(String(this.value));
  }

  formResetCallback() {
    this.setAttribute('value', this.initial);
    this.show();
  }
}

customElements.define('pad-stepper', PadStepper);
/* The shadow root of <pad-stepper>. */

:host { display: inline-flex; align-items: center; border: 1px solid var(--line, #ccc); border-radius: 999px; background: var(--surface, #fff); }
:host(:focus-within) { box-shadow: 0 0 0 4px var(--accent-soft, #fde); }
button { all: unset; cursor: pointer; width: 38px; height: 38px; text-align: center; font-size: 20px; color: var(--accent, #e8590c); }
button:focus-visible { outline: 2px solid var(--accent, #e8590c); border-radius: 50%; }
output { min-width: 34px; text-align: center; font: 700 18px/1 system-ui, sans-serif; }