일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- live share
- react-dom
- browserslist
- 우아한테크코스 레벨3
- Docker
- javascript
- GitHub Pages
- webpack
- 우아한테크코스
- Shadow DOM
- custom element
- web component
- Grafana
- fastify
- swiper
- 터치 스와이프
- 우테코
- scroll-snap
- RTL8852BE
- typescript
- HTML
- react
- 데모데이
- github Actions
- docker-compose
- CSS
- 유한 상태 머신
- Prometheus
- 무선랜카드 교체
- AX210
- Today
- Total
IT일상
[Web Component] 커스텀 폼 컨트롤, form associated 컴포넌트 만들기 본문
리뉴얼 된 블로그로 보기: 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
'프론트엔드' 카테고리의 다른 글
타입스크립트 Discriminated Unions (0) | 2023.03.27 |
---|---|
타입스크립트 템플릿 리터럴 타입 (0) | 2023.03.27 |
[Web Component] Custom Element에서 slot 사용하기 (2) | 2023.03.06 |
[Web Component] 컴포넌트 내부 요소를 격리하기. HTML Custom Elements + Shadow DOM (0) | 2023.02.27 |
GitHub Actions: Webpack 빌드 + GitHub Pages 배포 자동화하기 (15) | 2023.02.24 |