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