IT일상

[Web Component] 커스텀 폼 컨트롤, form associated 컴포넌트 만들기 본문

프론트엔드

[Web Component] 커스텀 폼 컨트롤, form associated 컴포넌트 만들기

solo5star 2023. 3. 7. 00:05
리뉴얼 된 블로그로 보기: https://solo5star.dev/posts/29/

 

<body>
  <h1>🏆 로또를 구매하세요~</h1>

  <form>
    <my-lotto-input></my-lotto-input>

    <button>구매하기</button>
  </form>
</body>

 

로또를 구매하는 폼을 만들고자 합니다.

 

<my-lotto-input> 엘리먼트는

  1) 1~45의 숫자 6개를 받아야 하며

  2) 서로 중복되지 않는 번호인지

검증해야 합니다.

 

customElements.define('my-lotto-input', class MyLottoInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          display: block;
          margin-bottom: 1rem;
        }

        input[type="number"] {
          font-size: 2rem;
          width: 4rem;
        }
      </style>

      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
    `;
  }
});

<my-lotto-input> 커스텀 폼 컨트롤은 위와 같이 만들 수 있습니다.

 

하지만 이렇게 만들더라도 form은 이 input이 올바른지, 어떠한 값을 가지고 있는지 등 정보를 알 수 없습니다.

console.log(document.querySelector('form').elements);

form이 가지고 있는 폼 컨트롤을 출력해보아도, my-lotto-input 엘리먼트는 표시되지 않습니다.

 

 

Form associated

커스텀 폼 컨트롤이 form에 참여하려면 form associated 를 활성화하면 됩니다. form에 참여함으로서 얻을 수 있는 이점은 다음과 같습니다.

 

* 커스텀 폼 컨트롤이 있다는 사실을 form이 인지할 수 있다.

* 커스텀 폼 컨트롤의 이 form이 submit될 때 같이 submit된다.

* form validation에 참여할 수 있다. 커스텀 폼 컨트롤의 값이 올바르지 않을 시, :valid와 :invalid의 pseudo class를 사용하여 스타일을 지정할 수 있다.

* form이 reset될 때 콜백을 받을 수 있다.

* reload됨으로 인해 form이 restore될 때 콜백을 받을 수 있다.

 

이에 대한 내용은 하나씩 알아보도록 하겠습니다.

 

  <form>
    <my-lotto-input name="lotto"></my-lotto-input>

    <button>구매하기</button>
  </form>
customElements.define('my-lotto-input', class MyLottoInput extends HTMLElement {
  static get formAssociated() {
    return true;
  }

  constructor() {
   // ... 
  }
});

 

form associated로 만드는 방법은 간단합니다. 정적 멤버변수 formAssociated를 true로 설정해주시면 됩니다.

 

form에서 my-lotto-input 엘리먼트를 폼 컨트롤로 인식한 모습을 볼 수 있습니다.

 

 

Form Control Validation

form associated로 설정할 시 폼 컨트롤은 스스로 값이 올바른지 검증할 수 있습니다.

customElements.define('my-lotto-input', class MyLottoInput extends HTMLElement {
  static get formAssociated() {
    return true;
  }

  internals = this.attachInternals(); // ElementInternals 객체를 반환하며, form과 소통하는 데 사용

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
    `;
  }

  connectedCallback() {
    this.shadowRoot.querySelectorAll('input').forEach(($input) => {
      $input.addEventListener('blur', () => this.onChange());
    })
  }

  onChange() {
    const numbers = [];
    for (const $input of this.shadowRoot.querySelectorAll('input')) {
      if (!$input.value) {
        this.internals.setValidity(
          { valueMissing: true }, // 어떠한 유형의 검증 실패인지
          '빈 값을 입력할 수 없습니다!', // 표시할 메세지
          $input, // 검증 실패가 일어난 엘리먼트 (검증 실패 메세지가 나타날 엘리먼트)
        );
        this.internals.reportValidity(); // 검증 결과를 사용자에게 알림
        return;
      }
      const number = Number($input.value);
      if (numbers.includes(number)) {
        this.internals.setValidity({ badInput: true }, '중복된 숫자를 입력할 수 없습니다!', $input);
        this.internals.reportValidity();
        return;
      }
      numbers.push(number);
    }
    this.internals.setValidity({}); // 검증 실패 상태 해제
    this.internals.setFormValue(JSON.stringify(numbers));
  }
});

검증 실패 메시지가 나타나는 것은, ElementInternals.reportValidity를 호출하였기 때문입니다. 호출하지 않는다면 메세지가 나타나지 않을 겁니다.

 

ElementInternals.setValidity()의 인자가 가질 수 있는 프로퍼티는 다음과 같습니다.

ElementInternals.setValidity({ tooLong: true }) 와 같은 식으로 사용할 수 있습니다. 오류의 상황에 따라 골라서 사용하시면 됩니다.

 

ElementInternals.setValidity({}) 처럼 아무것도 넣지 않으면 올바른 상태로 설정됩니다.

 

 

Form Control Submit

form이 submit될 때, 폼 컨트롤의 값이 같이 제출되도록 할 수 있습니다. ElementInternals.setFormValue를 사용하면 됩니다.

customElements.define('my-lotto-input', class MyLottoInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
    `;
  }

  connectedCallback() {
    this.shadowRoot.querySelectorAll('input').forEach(($input) => {
      $input.addEventListener('blur', () => this.onChange());
    })
  }

  onChange() {
    const numbers = [...this.shadowRoot.querySelectorAll('input')].map(($input) => Number($input.value));
    this.internals.setFormValue(JSON.stringify(numbers)); // submit 시 제출될 값을 설정
  }
});

document.querySelector('form').addEventListener('submit', (event) => {
  event.preventDefault();
  const $form = event.target;
  console.log([...new FormData($form).entries()]);
});

form에 submit 이벤트를 걸고 FormData를 통해 제출된 데이터를 확인해 볼 수 있습니다.

form submit 시, 값이 함께 잘 제출되는 것을 볼 수 있습니다.

 

 

Form Control Reset

form.reset() 또는 <button type="reset" /> 클릭 시 form의 입력 값들이 초기화 됩니다. form associated 에서는 form이 reset될 때 콜백을 받을 수 있습니다. 콜백에서 값들을 초기화하면 됩니다.

 

  <form>
    <my-lotto-input name="lotto"></my-lotto-input>

    <button>구매하기</button>
    <button type="reset">초기화</button>
  </form>
customElements.define('my-lotto-input', class MyLottoInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
    `;
  }

  formResetCallback() {
    this.shadowRoot.querySelectorAll('input').forEach(($input) => $input.value = '');
  }
});

formResetCallback 메소드를 추가하고 reset시의 동작을 정의하면 됩니다.

 

 

 

Form Control Restore

form control에 채웠던 값을 되돌릴 때 사용됩니다. form이 있는 페이지에서 실수로 다른 페이지에 들어갔다가 뒤로 가기를 눌렀을 때와 같은 상황에서 restore가 발생합니다. 이처럼 restore를 할 지 말지는 브라우저가 결정합니다.

 

콜백 함수의 시그니처는 formStateRestoreCallback(state, mode) 입니다. state는 setFormValue로 넘겨주었던 값이며, mode는 "restore" 또는 "autocomplete" 중 하나입니다.

 

customElements.define('my-lotto-input', class MyLottoInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
    `;
  }

  formStateRestoreCallback(state, mode) {
    if (mode === "restore") {
      const numbers = JSON.parse(state);
      console.log(`Restored! state=${state}, mode=${mode}`);
      this.shadowRoot.querySelectorAll('input').forEach(($input, i) => $input.value = numbers[i]);
    }
  }
});

네이버로 이동했다가 다시 뒤로가기를 눌렀을 때 restore가 잘 동작하는 것을 볼 수 있습니다.

 

 

form:invalid, form:valid

폼 컨트롤이 스스로 올바르지 않은 상태일 때, form:invalid CSS를 사용할 수 있습니다. 반대로 form이 가지고 있는 모든 폼 컨트롤 요소가 올바른 상태라면 form:valid가 활성화됩니다.

 

<style>
  body:has(form:invalid) {
    background-color: pink;
  }
</style>

<body>
  <h1>🏆 로또를 구매하세요~</h1>

  <form>
    <my-lotto-input name="lotto"></my-lotto-input>

    <button>구매하기</button>
    <button type="reset">초기화</button>
  </form>

  <p id="result"></p>
</body>

 

 

전체 코드

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    body:has(form:invalid) {
      background-color: pink;
    }
  </style>
</head>

<body>
  <h1>🏆 로또를 구매하세요~</h1>

  <form>
    <my-lotto-input name="lotto"></my-lotto-input>

    <button>구매하기</button>
    <button type="reset">초기화</button>
  </form>

  <p id="result"></p>
</body>

<script>
customElements.define('my-lotto-input', class MyLottoInput extends HTMLElement {
  static get formAssociated() {
    return true;
  }

  internals = this.attachInternals();

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host {
          margin-bottom: 1rem;
        }

        input[type="number"] {
          font-size: 2rem;
          width: 4rem;
        }
      </style>

      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
      <input type="number" min="1" max="45">
    `;
  }

  connectedCallback() {
    this.shadowRoot.querySelectorAll('input').forEach(($input) => {
      $input.addEventListener('blur', () => this.onChange());
    });
  }

  formResetCallback() {
    this.shadowRoot.querySelectorAll('input').forEach(($input) => $input.value = '');
  }

  formStateRestoreCallback(state, mode) {
    if (mode === "restore") {
      const numbers = JSON.parse(state);
      console.log(`Restored! state=${state}, mode=${mode}`);
      this.shadowRoot.querySelectorAll('input').forEach(($input, i) => $input.value = numbers[i]);
    }
  }

  onChange() {
    const numbers = [];
    for (const $input of this.shadowRoot.querySelectorAll('input')) {
      if (!$input.value) {
        this.internals.setValidity({ valueMissing: true }, '빈 값을 입력할 수 없습니다!', $input);
        this.internals.reportValidity();
        return;
      }
      const number = Number($input.value);
      if (numbers.includes(number)) {
        this.internals.setValidity({ badInput: true }, '중복된 숫자를 입력할 수 없습니다!', $input);
        this.internals.reportValidity();
        return;
      }
      numbers.push(number);
    }
    this.internals.setValidity({});
    this.internals.setFormValue(JSON.stringify(numbers));
  }
});

document.querySelector('form').addEventListener('submit', (event) => {
  event.preventDefault();
  const $form = event.target;
  const formData = Object.fromEntries(new FormData($form).entries());
  const lottoStr = JSON.parse(formData['lotto']).join(', ');

  document.querySelector('#result').innerText = '당신이 구매한 로또는 ' + lottoStr + '입니다!';
});
</script>
</html>

 

 

맺음말

form associated를 활성화하여 폼 컨트롤을 만들면 form에 참여할 수 있으며 좀 더 자율적인 엘리먼트로 만들 수 있었습니다. 지금까지는 엘리먼트의 값을 document.querySelector로 선택하여 외부에서 모두 처리하였지만 이 글에서 소개한 방법처럼, 폼 컨트롤 스스로가 더 많은 일을 하도록 하는 것에 대해서도 생각해보면 좋을 것 같습니다.

 

 

참고 자료

* https://web.dev/more-capable-form-controls/

* https://html.spec.whatwg.org/dev/custom-elements.html#form-associated-custom-elements

Comments