<body>
<h1>🏆 로또를 구매하세요~</h1>
<form>
<my-lotto-input></my-lotto-input>
<button>구매하기</button>
</form>
</body>
로또를 구매하는 폼을 만들고자 합니다.
<my-lotto-input>
엘리먼트는
- 1~45의 숫자 6개를 받아야 하며
- 서로 중복되지 않는 번호인지
검증해야 합니다.
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
로 선택하여 외부에서 모두 처리하였지만 이 글에서 소개한 방법처럼, 폼 컨트롤 스스로가 더 많은 일을 하도록 하는 것에 대해서도 생각해보면 좋을 것 같습니다.