diff --git a/CHANGELOG.md b/CHANGELOG.md index dd13ec3..d451ecc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.2.0 (2026-07-17) + +The Korean release. / 한국 특화 릴리스. + +- **`toKorean(canonical)`** — mixed digits-plus-units amount reading: + `'1500000'` → `"150만"`, `'927483041001'` → `"9,274억 8,304만 1,001"`. + Zero groups omitted, per-group comma grouping, sign kept, fraction + ignored, units up to 양 (10^28). / 한글 금액 읽기 — 은행·핀테크 UI의 + "150만" 병기를 함수 하나로. +- **`data-numkey-korean`** — live reading next to the input: empty value + generates a `` after the field (style it + yourself; add "원" via CSS `::after`), or pass a CSS selector to use an + existing element. / 인풋 옆 실시간 한글 병기 — 마크업만으로. +- **`data-numkey-name="amount"`** — generated hidden input that always + carries the settled CANONICAL value, so a classic form POST submits + `1234567` while the field shows `1,234,567`. No server-side comma + stripping needed. / 클래식 폼 전송용 hidden 정식 값 동기화 — JSP/PHP + 폼에서 서버측 콤마 제거가 필요 없어집니다. + ## 0.1.0 (2026-07-17) Initial release. / 최초 릴리스. diff --git a/README.ko.md b/README.ko.md index 88833a5..319915e 100644 --- a/README.ko.md +++ b/README.ko.md @@ -77,6 +77,8 @@ numkey는 그 인풋을 한 번에 끝냅니다: | `data-numkey-separator=" "` | 그룹 구분자 (기본 `,`) | | `data-numkey-point=","` | 필드에 표시되는 소수점 (기본 `.`) | | `data-numkey-locale` | 로케일에서 구분자 유도 — 아래 참조 | +| `data-numkey-korean` | 실시간 한글 금액 병기 ("150만") — 아래 참조 | +| `data-numkey-name="amount"` | 정식 값을 전송하는 hidden 인풋 — 아래 참조 | ### 로케일 표시 (옵트인) @@ -100,7 +102,49 @@ numkey는 그 인풋을 한 번에 끝냅니다: 로케일이 바꾸는 건 **그리는 방식뿐**입니다. 정식 값은 항상 `"1234567.89"` — 세 경우 모두 `numkey.getValue(el)`이 같은 문자열을 돌려줍니다. 일반 폼 POST는 *표시 값*을 전송하므로, 로케일을 쓰는 폼은 -제출 전에 `getValue`를 hidden 필드에 담거나 서버에서 정규화하세요. +`data-numkey-name`(아래)으로 전송하거나 서버에서 정규화하세요. + +### 한글 금액 병기 + +은행·핀테크 UI가 금액 필드 옆에 그리는 "150만" 힌트 — 프로젝트마다 +손으로 다시 만드는 바로 그것: + +```html + + + + +``` + +속성이 빈 값이면 인풋 바로 뒤에 ``을 생성합니다 +(무스타일 — "원"은 CSS로: `.numkey-korean::after { content: " 원" }`). +값을 주면 기존 요소의 CSS 셀렉터로 해석합니다. 같은 엔진을 순수 함수로도 +쓸 수 있습니다: + +```ts +import { toKorean } from '@devslab/numkey' + +toKorean('1500000') // "150만" +toKorean('927483041001') // "9,274억 8,304만 1,001" +toKorean('100000001') // "1억 1" — 0인 그룹은 생략 +``` + +### 정식 값으로 전송하기 (`data-numkey-name`) + +일반 폼 POST는 필드에 보이는 그대로 — `1,234,567` — 전송하고, 서버는 매번 +콤마를 제거해야 합니다. `data-numkey-name`은 정리된 정식 값을 항상 담고 +있는 hidden 인풋을 생성합니다: + +```html +
+ + +
+``` + +hidden 값은 입력 중간 상태에서도 정리된 상태를 유지하므로 (`1,234.`는 +`1234`로 전송), 어느 순간 제출되든 서버는 깨끗한 숫자를 받습니다. > `type="text"` 인풋을 쓰세요. numkey가 `inputmode`를 설정해 모바일에서 숫자 > 키패드가 뜹니다. `type="number"`는 커서 API가 없어 포맷팅과 충돌합니다. @@ -173,6 +217,7 @@ const [amount, setAmount] = useState('') | `parse(display, opts?)` | 표시 값/붙여넣기 → 정식 값 `"1234567.89"` | | `format(canonical, opts?)` | 정식 값 → 표시 값 `"1,234,567.89"` | | `finalize(canonical)` | 입력 중간 상태 정리 (`"1234."` → `"1234"`) | +| `toKorean(canonical, opts?)` | 한글 금액 병기 (`"1500000"` → `"150만"`) | ### DOM @@ -192,8 +237,7 @@ const [amount, setAmount] = useState('') - 구분자 바로 뒤에서 백스페이스를 누르면 커서가 구분자를 지나칩니다 (숫자는 다음 백스페이스에서 삭제) — 주요 마스킹 라이브러리들과 같은 동작입니다. 구분자 건너뛰기 삭제는 로드맵에 있습니다. -- 로드맵: 한글 금액 병기 (`1500000` → “150만”), 만/억 축약 파싱 - (`3만5천` → `35000`), 클래식 폼 전송용 hidden 필드 정식 값 동기화. +- 로드맵: 만/억 축약 파싱 (`3만5천` → `35000`), 구분자 건너뛰기 삭제. ## License diff --git a/README.md b/README.md index 8267182..a3afbe8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ it does nothing: | `data-numkey-separator=" "` | group separator (default `,`) | | `data-numkey-point=","` | decimal mark shown in the field (default `.`) | | `data-numkey-locale` | derive separators from a locale — see below | +| `data-numkey-korean` | live Korean amount reading ("150만") — see below | +| `data-numkey-name="amount"` | hidden input posting the canonical value — see below | ### Locale-aware display (opt-in) @@ -102,8 +104,50 @@ forms usually need. `data-numkey-locale` opts a field into locale separators: The locale changes **only how the value is drawn**. The canonical value is always `"1234567.89"` — `numkey.getValue(el)` returns the same string in all three cases. Since a plain form POST submits the *display* value, a form -using locales should read `getValue` into a hidden field (or normalize -server-side) before submitting. +using locales should post through `data-numkey-name` (below) or normalize +server-side. + +### Korean amount reading (한글 금액 병기) + +The "150만" hint that banking and fintech UIs render next to amount fields — +usually rebuilt by hand in every project: + +```html + + + + +``` + +An empty attribute generates a `` right after the +input (unstyled — add "원" with CSS `.numkey-korean::after { content: " 원" }`); +a value is treated as a CSS selector for an existing element. The same engine +is available as a pure function: + +```ts +import { toKorean } from '@devslab/numkey' + +toKorean('1500000') // "150만" +toKorean('927483041001') // "9,274억 8,304만 1,001" +toKorean('100000001') // "1억 1" — zero groups omitted +``` + +### Posting the canonical value (`data-numkey-name`) + +A plain form POST submits what the field displays — `1,234,567` — which every +server handler then has to de-comma. `data-numkey-name` generates a hidden +input that always carries the settled canonical value instead: + +```html +
+ + +
+``` + +The hidden value is kept settled even mid-typing (`1,234.` posts `1234`), so +whatever moment the form submits, the server sees a clean number. > Use `type="text"` inputs. numkey sets `inputmode` so mobile keyboards show > the numeric keypad; `type="number"` has no caret API and fights formatting. @@ -176,6 +220,7 @@ const [amount, setAmount] = useState('') | `parse(display, opts?)` | display/paste mess → canonical `"1234567.89"` | | `format(canonical, opts?)` | canonical → display `"1,234,567.89"` | | `finalize(canonical)` | settle transient typing states (`"1234."` → `"1234"`) | +| `toKorean(canonical, opts?)` | Korean amount reading (`"1500000"` → `"150만"`) | ### DOM @@ -195,8 +240,8 @@ const [amount, setAmount] = useState('') - Backspacing directly over a separator moves the caret past it (the digit is deleted on the next backspace) — the same behavior as the major masking libraries. Smart separator-skipping deletion is on the roadmap. -- Roadmap: Korean unit reading (`1500000` → “150만”), 만/억 shorthand parsing - (`3만5천` → `35000`), hidden-field canonical sync for classic form posts. +- Roadmap: 만/억 shorthand parsing (`3만5천` → `35000`), smart + separator-skipping deletion. ## License diff --git a/package.json b/package.json index 4b48287..6a27781 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/numkey", - "version": "0.1.0", + "version": "0.2.0", "publishConfig": { "access": "public" }, diff --git a/site/index.html b/site/index.html index 4d318dc..90bccd7 100644 --- a/site/index.html +++ b/site/index.html @@ -51,6 +51,8 @@ } .canonical { font-size: 0.85rem; color: var(--muted); margin-top: 0.35rem; } .canonical code { color: var(--accent); } + .numkey-korean { margin-left: 0.6rem; color: var(--accent); font-size: 0.95rem; } + .numkey-korean:not(:empty)::after { content: " 원"; color: var(--muted); } pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; padding: 0.9rem 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.5; @@ -90,6 +92,26 @@

Try it — everything below is just markup / 전부 마크 +
+

Korean amount reading / 한글 금액 병기 — 은행 UI의 "150만"을 마크업만으로

+ + + +

+ The generated <span class="numkey-korean"> is unstyled — the + "원" here comes from CSS ::after. Also a pure function: + toKorean('927483041001')9,274억 8,304만 1,001. +

+
+ +
+

Form POST, solved / 폼 전송은 hidden이 정식 값으로 — 서버 콤마 제거 불필요

+ + + +
the hidden input will POST:
+
+

Locale (opt-in) / 로케일은 옵트인 — 기본값은 브라우저와 무관하게 고정

@@ -158,6 +180,17 @@

No build step / JSP·PHP·정적 페이지 — 스크립트 numkey.setValue(locEl, canonical) syncLoc() }) + + // show what the data-numkey-name hidden input will POST — computed via + // getValue so it doesn't depend on listener ordering vs numkey's own sync + var postedEl = document.getElementById('posted') + var postedOut = document.getElementById('posted-out') + var syncPosted = function () { + postedOut.textContent = 'amount=' + numkey.getValue(postedEl) + } + postedEl.addEventListener('input', syncPosted) + postedEl.addEventListener('blur', syncPosted) + syncPosted() diff --git a/src/dom.test.ts b/src/dom.test.ts index c4447f8..4535253 100644 --- a/src/dom.test.ts +++ b/src/dom.test.ts @@ -163,6 +163,66 @@ describe('data-numkey-locale — opt-in locale formatting', () => { }) }) +describe('data-numkey-korean — live amount reading', () => { + it('generates a span after the input and keeps it in sync', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean': '' }) + el.value = '1500000' + const unbind = bind(el) + const span = el.nextElementSibling as HTMLSpanElement + expect(span.className).toBe('numkey-korean') + expect(span.textContent).toBe('150만') // server value read at bind + + feed(el, '927483041001') + expect(span.textContent).toBe('9,274억 8,304만 1,001') + + unbind() + expect(el.nextElementSibling).toBeNull() // generated span removed + }) + + it('a selector value targets an existing element instead', () => { + const target = document.createElement('div') + target.id = 'hint' + document.body.appendChild(target) + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean': '#hint' }) + const unbind = bind(el) + feed(el, '15000') + expect(target.textContent).toBe('1만 5,000') + unbind() + expect(document.getElementById('hint')).not.toBeNull() // not ours to remove + }) +}) + +describe('data-numkey-name — hidden canonical sync for form POSTs', () => { + it('generates a hidden input that carries the canonical value', () => { + const el = makeInput({ 'data-numkey': '2', 'data-numkey-name': 'amount' }) + el.value = '1234567.89' + const unbind = bind(el) + const hidden = el.nextElementSibling as HTMLInputElement + expect(hidden.type).toBe('hidden') + expect(hidden.name).toBe('amount') + expect(el.value).toBe('1,234,567.89') // visible: display + expect(hidden.value).toBe('1234567.89') // posted: canonical + + feed(el, '1,234,567.895') // over the decimal budget → truncated + expect(hidden.value).toBe('1234567.89') + + feed(el, '500') + expect(hidden.value).toBe('500') + + unbind() + expect(el.nextElementSibling).toBeNull() // generated hidden removed + }) + + it('the hidden value is settled even while the display is transient', () => { + const el = makeInput({ 'data-numkey': '2', 'data-numkey-name': 'amount' }) + bind(el) + const hidden = el.nextElementSibling as HTMLInputElement + feed(el, '1234.') + expect(el.value).toBe('1,234.') // transient display kept for typing + expect(hidden.value).toBe('1234') // canonical already settled + }) +}) + describe('observe — auto-init', () => { it('binds existing and later-added [data-numkey] inputs', async () => { const existing = makeInput({ 'data-numkey': '' }) diff --git a/src/dom.ts b/src/dom.ts index 0d1195d..c347d82 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -5,6 +5,12 @@ * — 2 decimals: 1,234.56 * — minus allowed * — opt out of right-align + * — live "150만" reading in a + * generated after the input (or give the + * attribute a CSS selector to use an existing element) + * — keep a generated hidden + * input named "amount" in sync with the CANONICAL value, so a classic + * form POST submits "1234567" while the field shows "1,234,567" * * Binding sets `inputmode` (numeric/decimal) and right-aligns the field * unless either is already set, formats any server-rendered value in place, @@ -25,6 +31,7 @@ import { parse, type NumkeyOptions } from './core' +import { toKorean } from './korean' const SELECTOR = 'input[data-numkey]' @@ -139,22 +146,66 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void { ) { el.style.textAlign = 'right' } + + // data-numkey-korean: live "150만" reading. Empty value → generate a span + // right after the input; a value is a selector for an existing element. + const koreanAttr = el.getAttribute('data-numkey-korean') + let koreanTarget: Element | null = null + let koreanCreated = false + if (koreanAttr !== null) { + if (koreanAttr === '') { + koreanTarget = document.createElement('span') + koreanTarget.className = 'numkey-korean' + el.insertAdjacentElement('afterend', koreanTarget) + koreanCreated = true + } else { + koreanTarget = document.querySelector(koreanAttr) + } + } + + // data-numkey-name: hidden input carrying the canonical value for form + // POSTs. Always generated (and removed on unbind). + const hiddenName = el.getAttribute('data-numkey-name') + let hidden: HTMLInputElement | null = null + if (hiddenName) { + hidden = document.createElement('input') + hidden.type = 'hidden' + hidden.name = hiddenName + el.insertAdjacentElement('afterend', hidden) + } + + const syncExtras = (o: NumkeyOptions): void => { + if (!koreanTarget && !hidden) return + const canonical = finalize(parse(el.value, o)) + if (koreanTarget) koreanTarget.textContent = toKorean(canonical) + if (hidden) hidden.value = canonical + } + applyToInput(el, initial) + syncExtras(initial) let composing = false + const run = (): void => { + const o = resolve() + applyToInput(el, o) + syncExtras(o) + } + const onCompositionStart = (): void => { composing = true } const onCompositionEnd = (): void => { composing = false - applyToInput(el, resolve()) + run() } const onInput = (): void => { - if (!composing) applyToInput(el, resolve()) + if (!composing) run() } const onBlur = (): void => { - finalizeInput(el, resolve()) + const o = resolve() + finalizeInput(el, o) + syncExtras(o) } el.addEventListener('compositionstart', onCompositionStart) @@ -167,6 +218,8 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void { el.removeEventListener('compositionend', onCompositionEnd) el.removeEventListener('input', onInput) el.removeEventListener('blur', onBlur) + if (koreanCreated) koreanTarget?.remove() + hidden?.remove() unbinders.delete(el) } unbinders.set(el, unbind) diff --git a/src/index.ts b/src/index.ts index 22557d8..ce5a6d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export { resolveOptions, type NumkeyOptions } from './core' +export { toKorean, type ToKoreanOptions } from './korean' export { applyToInput, bind, diff --git a/src/korean.test.ts b/src/korean.test.ts new file mode 100644 index 0000000..90f797a --- /dev/null +++ b/src/korean.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { toKorean } from './korean' + +describe('toKorean — mixed digits-plus-units reading', () => { + it('reads 만-scale amounts the way banking UIs show them', () => { + expect(toKorean('1500000')).toBe('150만') + expect(toKorean('15000')).toBe('1만 5,000') + expect(toKorean('50000000')).toBe('5,000만') + expect(toKorean('100000000')).toBe('1억') + }) + + it('handles multi-unit values with per-group comma grouping', () => { + expect(toKorean('927483041001')).toBe('9,274억 8,304만 1,001') + expect(toKorean('123456789')).toBe('1억 2,345만 6,789') + }) + + it('omits zero groups entirely', () => { + expect(toKorean('100000001')).toBe('1억 1') + expect(toKorean('1000050000')).toBe('10억 5만') + }) + + it('below 만 there is no unit — plain comma grouping', () => { + expect(toKorean('1234')).toBe('1,234') + expect(toKorean('7')).toBe('7') + }) + + it('handles zero, empty, and transient states', () => { + expect(toKorean('0')).toBe('0') + expect(toKorean('')).toBe('') + expect(toKorean('-')).toBe('') // finalize settles the lone minus + expect(toKorean('1234.')).toBe('1,234') + }) + + it('keeps the sign and ignores the fraction', () => { + expect(toKorean('-15000')).toBe('-1만 5,000') + expect(toKorean('1500000.75')).toBe('150만') + }) + + it('spacing: false joins the groups', () => { + expect(toKorean('1505000', { spacing: false })).toBe('150만5,000') + }) + + it('supports 조 and 경', () => { + expect(toKorean('1000000000000')).toBe('1조') + expect(toKorean('10000000000000000')).toBe('1경') + }) +}) diff --git a/src/korean.ts b/src/korean.ts new file mode 100644 index 0000000..af3d078 --- /dev/null +++ b/src/korean.ts @@ -0,0 +1,47 @@ +/** + * Korean amount reading — the "150만" hint that banking/fintech UIs render + * next to amount inputs, in the mixed digits-plus-units style: + * + * toKorean('1500000') // "150만" + * toKorean('927483041001') // "9,274억 8,304만 1,001" + * + * Digits are grouped by 만 (10^4); zero groups are omitted; each group keeps + * its own 3-digit comma grouping. The reading covers the INTEGER part only + * (a fraction, rare on 금액 fields, is ignored). Append "원" (or style it in + * via CSS ::after) yourself — the unit of account is the caller's business. + */ +import { finalize, format } from './core' + +const UNITS = ['', '만', '억', '조', '경', '해', '자', '양'] + +export interface ToKoreanOptions { + /** Space between unit groups ("150만 5,000" vs "150만5,000"). Default true. */ + spacing?: boolean +} + +/** Canonical numeric string → mixed Korean unit reading. */ +export function toKorean(canonical: string, opts?: ToKoreanOptions): string { + const settled = finalize(canonical) + if (settled === '') return '' + + const neg = settled.startsWith('-') + let int = neg ? settled.slice(1) : settled + const point = int.indexOf('.') + if (point !== -1) int = int.slice(0, point) + if (int === '' || /^0*$/.test(int)) return '0' + + // split from the right into 만-groups of 4 digits + const groups: string[] = [] + for (let i = int.length; i > 0; i -= 4) { + groups.unshift(int.slice(Math.max(0, i - 4), i)) + } + if (groups.length > UNITS.length) return format(settled) // beyond 양 (10^32) + + const parts: string[] = [] + for (let i = 0; i < groups.length; i++) { + const value = parseInt(groups[i] as string, 10) + if (value === 0) continue + parts.push(format(String(value)) + UNITS[groups.length - 1 - i]) + } + return (neg ? '-' : '') + parts.join(opts?.spacing === false ? '' : ' ') +}