<h1>Progressively Enchanced Stepper Web Component</h1>

<div class="two-up">
<form class="form" id="quantity-form" action="#">
  <div class="field">
    <label for="stepper">Quantity 1 (using data-is)</label>
    <input type="number" id="stepper" name="quantity-1" value="0" data-is="my-stepper">
  </div>
  <div class="field">
    <label for="stepper2">Quantity 2 (input as child)</label>
    <my-stepper>
      <input type="number" id="stepper2" name="quantity-2" value="0">
    </my-stepper>
  </div>
  <div class="field">
    <button class="button">Check form data</button>
  </div>
</form>

<div class="result">
  <h2>Form Data</h2>
  <pre id="report"></pre>
</div>
</div>

<p>This is my attempt at upgrading a form element to an enhanced Web Component. The benefit here is if JavaScript doesn't load, the input functions like a plain old number input.</p>
<p>Since the inputs never leave the light DOM, they work seamlessly. IDs work with the labels, the form detects the values, etc.</p>
html {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
  line-height: 1.5;
}

p {
  max-width: 60ch;
}

input[type=number] {
  -moz-appearance: textfield;
  text-align: center;
  border: solid 2px black;
  border-radius: 6px;
  max-width: 75px;
}

my-stepper:defined input[type=number]::-webkit-inner-spin-button, 
my-stepper:defined input[type=number]::-webkit-outer-spin-button { 
  -webkit-appearance: none; 
  margin: 0; 
}

my-stepper::part(wrapper) {
  position: relative;
}

my-stepper::part(control) {
  position: absolute;
  -webkit-appearance: none;
  background: transparent;
  border: none;
  height: 100%;
  top: 1px;
}

my-stepper::part(increment) {
  right: 0;
}

.form {
  background-color: #eee;
  padding: 1.5rem;
}

.form > * + * {
  margin-top: 0.75rem;
}

.field label {
  display: block;
  font-weight: 700;
}

.field > * + * {
  margin-top: 0.25rem;
}

.button {
  -webkit-appearance: none;
  border: none;
  font: inherit;
  font-weight: 700;
  color: white;
  background-color: #333;
  border-radius: 6px;
}

.result {
  padding: 1.5rem;
}

.result > * {
  margin: 0;
}

.result > * + * {
  margin-top: 0.75rem;
}

.two-up {
  display: flex;
  width: min(100%, 80ch);
  flex-wrap: wrap;
}

.two-up > * {
  flex-grow: 1;
}
const template = document.createElement("template");

template.innerHTML = `

<span part="wrapper">
  <button part="decrement control" tabindex="-1" aria-hidden="true">&minus;</button>
  <slot>REPLACE ME</slot>
  <button part="increment control" tabindex="-1" aria-hidden="true">+</button>
<span>
`;

class MyStepper extends HTMLElement {
  static validateElement(el) {
    if (notNumberInput(el)) throw new Error('Must be an input of type number!');
  }
  
  constructor() {
    super();
    
    this.attachShadow({ mode: "open" });
    this.shadowRoot.appendChild(template.content.cloneNode(true));
  }
  
  connectedCallback() {
    if (this.isConnected) {
      this.increment = this.increment.bind(this);
      this.decrement = this.decrement.bind(this);
      this.shadowRoot.querySelector("[part*=decrement]").addEventListener('click', this.decrement);
      this.shadowRoot.querySelector("[part*=increment]").addEventListener('click', this.increment);
    }
  }
  
  disconnectedCallback() {
    this.shadowRoot.querySelector("[part*=decrement]").removeEventListener('click', this.decrement);
    this.shadowRoot.querySelector("[part*=increment]").removeEventListener('click', this.increment);
  }
  
  increment(event) {
    if (this.firstElementChild) {
      this.firstElementChild.stepUp();
    }
  }
  
  decrement(event) {
    if (this.firstElementChild) {
      this.firstElementChild.stepDown();
    }
  }
}

customElements.define("my-stepper", MyStepper);

function notNumberInput(el) {
  return !(el instanceof HTMLInputElement && el.type === "number");
}

document.querySelectorAll("[data-is]").forEach((el) => {
  const cEl = el.dataset.is;
  delete el.dataset.is;

  if (cEl) {
    const CElement = customElements.get(cEl);
    CElement.validateElement?.(el);
    const cElInstance = new CElement();
    el.after(cElInstance);
    cElInstance.append(el);
  }
});

// form stuff here
const form = document.getElementById("quantity-form");

form.addEventListener("submit", function (event) {
  event.preventDefault();

  const data = new FormData(form);
  const entryObj = {};
  const reportDiv = document.getElementById('report')

  for (const [key, value] of data.entries()) {
    entryObj[key] = value;
  }
  
  reportDiv.textContent = JSON.stringify(entryObj, null, 2);
});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.