
휴대전화, 사업자번호 등 사용자가 입력한 값에 자동으로 하이픈(-)을 붙여주는 간단한 로직이 필요했습니다. 분명히 이전에도 설정했던 로직인데도 또 다시 머리를 싸매게되어 미래의 나를 위해 정리해둡니다.
type="text" + inputMode="numeric" (010 등 0으로 시작하는 값 반영)const formatNumberText = (raw: string, format: number[]) => {
const result: string[] = []
let cursor = 0
for (const len of format) {
const part = raw.slice(cursor, cursor + len)
if (part) result.push(part)
cursor += len
}
return result.join('-')
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value.replace(/\D/g, '')
const formatted = formatNumberText(raw, numberTextFormat)
// props로 받은 value, (formatted string을 파라미터로 받는) setValue
setValue(formatted)
}
이 방식은 입력 자체는 잘 되지만, 예상치 못한 문제가 생깁니다. 010-123 상태에서 두번째 0을 지우려고 커서를 1 앞으로 이동하여 Backspace를 누르면, -가 지워지고 010123로 변경된 value는 다시 포맷팅되어, 의도한 대로 지워지거나 이동하는 것이 아니라 버벅이는 느낌이 듭니다. 따라서 사용자가 지우려던 하이픈 앞 숫자로 자연스럽게 이동하기 위해 입력 전후 하이픈의 개수 차이를 계산해서 커서를 보정해줘야 합니다.
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const prevFormatted = prevFormattedRef.current
const maxLength = numberTextFormat.reduce((a, b) => a + b, 0)
const raw = e.target.value.replace(/\D/g, '').slice(0, maxLength)
const newFormatted = formatNumberText(raw, numberTextFormat)
// 커서 위치 보정
const cursor = e.target.selectionStart ?? newFormatted.length
requestAnimationFrame(() => {
const el = inputRef.current
if (!el) {
return
}
// 입력 전후 하이픈 개수 차이 → 커서가 밀려야 하는 거리
const prevOffset = prevFormatted.slice(0, cursor).replace(/\d/g, '').length
const newOffset = newFormatted.slice(0, cursor).replace(/\d/g, '').length
const diff = newOffset - prevOffset
const newCursor = cursor + diff
el.setSelectionRange(newCursor, newCursor)
})
setValue(newFormatted)
prevFormattedRef.current = newFormatted
}
이 방법으로도 충분하지만, 프로젝트에서는 파일, select 등 사용자에게 입력받는 컴포넌트들이 여러가지가 있었는데요. 이들의 공통적인 처리를 위하여 외부에서 change event를 받아 사용하고싶었습니다.
const fakeEvent = {
...e,
target: { ...e.target, value: newFormatted },
}
onChange(fakeEvent)
원래 있던 이벤트에 e.target.value만 변경하면 됐기 때문에, 위와 같이 이벤트를 만들어 onChange 함수를 호출하면 될 것이라 생각했습니다. 하지만 일부 DOM 속성들은 getter로만 존재하고 enumerable하지 않기 때문에 onChange 함수를 호출할 때에 e.target.id, name 등이 없었고, Uncaught TypeError: Illegal invocation 에러도 발생했습니다. 따라서 아래와 같이 Proxy 설정으로 우회가 필요했습니다.
const fakeEvent = Object.create(e)
Object.defineProperty(fakeEvent, 'target', {
value: new Proxy(e.target, {
get(target, prop) {
return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
},
}),
})
Object.defineProperty(fakeEvent, 'currentTarget', {
value: new Proxy(e.target, {
get(target, prop) {
return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
},
}),
})
onChange(fakeEvent as React.ChangeEvent<HTMLInputElement>)
이렇게 하면 value만 위장하고, id, name, dataset은 원래대로 유지됩니다.
아래는 textArea와 함께 사용한 코드 전문입니다.
const formatNumberText = (raw: string, format: number[]) => {
const result: string[] = []
let cursor = 0
for (const len of format) {
const part = raw.slice(cursor, cursor + len)
if (part) result.push(part)
cursor += len
}
return result.join('-')
}
const handleChange = (_e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
_e.preventDefault()
if (!numberTextFormat?.length) {
onChange(_e)
return
}
const e = _e as React.ChangeEvent<HTMLInputElement>
const maxLength = numberTextFormat.reduce((a, b) => a + b, 0)
const raw = e.target.value.replace(/\D/g, '').slice(0, maxLength)
const newFormatted = formatNumberText(raw, numberTextFormat)
const prevFormatted = value
// fake event 생성
const fakeEvent = Object.create(e)
Object.defineProperty(fakeEvent, 'target', {
value: new Proxy(e.target, {
get(target, prop) {
return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
},
}),
})
Object.defineProperty(fakeEvent, 'currentTarget', {
value: new Proxy(e.target, {
get(target, prop) {
return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
},
}),
})
onChange(fakeEvent as React.ChangeEvent<HTMLInputElement>)
// 커서 위치 보정
const cursor = e.target.selectionStart ?? newFormatted.length
requestAnimationFrame(() => {
const el = inputRef.current
if (!el) return
const prevOffset = prevFormatted.slice(0, cursor).replace(/\d/g, '').length
const newOffset = newFormatted.slice(0, cursor).replace(/\d/g, '').length
const diff = newOffset - prevOffset
const newCursor = cursor + diff
el.setSelectionRange(newCursor, newCursor)
})
}