diff --git a/CHANGELOG.md b/CHANGELOG.md index d451ecc..d754bfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.3.0 (2026-07-17) + +- **`fromKorean(text)`** — Korean-shorthand amount parsing, the inverse of + `toKorean`: `'3만5천'` → `"35000"`, `'1.5억'` → `"150000000"`, + `'삼십오만'` → `"350000"`. Digits + units (만/억/조/경, 천/백/십), full + Hangul digits, decimal units, commas/spaces/"원" ignored, bare unit = 1. + / 만/억 축약 파싱 — 부동산·주식 앱에서 실제로 입력하는 그대로. +- **`data-numkey-korean-entry`** / `koreanEntry` option — accept shorthand + in the field itself: a Korean draft is left alone while typing (no live + reformat fighting the IME) and converted on blur (`3만5천` → `35,000`). + `getValue` and the `data-numkey-name` hidden sync see the parsed value + even mid-draft. / 인풋에서 바로 축약 입력 — 조합 중에는 건드리지 않고 + blur에 변환, hidden 동기화는 초안 상태에서도 파싱된 값 유지. + ## 0.2.0 (2026-07-17) The Korean release. / 한국 특화 릴리스. diff --git a/README.ko.md b/README.ko.md index 319915e..f0a44f7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -78,6 +78,7 @@ numkey는 그 인풋을 한 번에 끝냅니다: | `data-numkey-point=","` | 필드에 표시되는 소수점 (기본 `.`) | | `data-numkey-locale` | 로케일에서 구분자 유도 — 아래 참조 | | `data-numkey-korean` | 실시간 한글 금액 병기 ("150만") — 아래 참조 | +| `data-numkey-korean-entry` | 만/억 축약 입력 허용 ("3만5천" → blur → "35,000") — 아래 참조 | | `data-numkey-name="amount"` | 정식 값을 전송하는 hidden 인풋 — 아래 참조 | ### 로케일 표시 (옵트인) @@ -130,6 +131,26 @@ toKorean('927483041001') // "9,274억 8,304만 1,001" toKorean('100000001') // "1억 1" — 0인 그룹은 생략 ``` +역방향도 됩니다 — 부동산·주식 앱에서 실제로 입력하는 축약형을 순수 함수로, +또는 필드에서 직접: + +```ts +import { fromKorean } from '@devslab/numkey' + +fromKorean('3만5천') // "35000" +fromKorean('1.5억') // "150000000" +fromKorean('삼십오만') // "350000" +``` + +```html + + +``` + +`getValue`와 `data-numkey-name` hidden 동기화(아래)는 초안이 화면에 있는 +동안에도 파싱된 값을 봅니다. + ### 정식 값으로 전송하기 (`data-numkey-name`) 일반 폼 POST는 필드에 보이는 그대로 — `1,234,567` — 전송하고, 서버는 매번 @@ -218,6 +239,7 @@ const [amount, setAmount] = useState('') | `format(canonical, opts?)` | 정식 값 → 표시 값 `"1,234,567.89"` | | `finalize(canonical)` | 입력 중간 상태 정리 (`"1234."` → `"1234"`) | | `toKorean(canonical, opts?)` | 한글 금액 병기 (`"1500000"` → `"150만"`) | +| `fromKorean(text)` | 만/억 축약 → 정식 값 (`"3만5천"` → `"35000"`) | ### DOM @@ -237,7 +259,7 @@ const [amount, setAmount] = useState('') - 구분자 바로 뒤에서 백스페이스를 누르면 커서가 구분자를 지나칩니다 (숫자는 다음 백스페이스에서 삭제) — 주요 마스킹 라이브러리들과 같은 동작입니다. 구분자 건너뛰기 삭제는 로드맵에 있습니다. -- 로드맵: 만/억 축약 파싱 (`3만5천` → `35000`), 구분자 건너뛰기 삭제. +- 로드맵: 구분자 건너뛰기 삭제. ## License diff --git a/README.md b/README.md index a3afbe8..5fd1231 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ it does nothing: | `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-korean-entry` | accept Korean shorthand entry ("3만5천" → blur → "35,000") — see below | | `data-numkey-name="amount"` | hidden input posting the canonical value — see below | ### Locale-aware display (opt-in) @@ -133,6 +134,26 @@ toKorean('927483041001') // "9,274억 8,304만 1,001" toKorean('100000001') // "1억 1" — zero groups omitted ``` +And the inverse — accept the shorthand people actually type into +부동산/주식 apps, either as a pure function or on the field itself: + +```ts +import { fromKorean } from '@devslab/numkey' + +fromKorean('3만5천') // "35000" +fromKorean('1.5억') // "150000000" +fromKorean('삼십오만') // "350000" +``` + +```html + + +``` + +`getValue` and the `data-numkey-name` hidden sync (below) see the parsed +value even while the draft is still on screen. + ### Posting the canonical value (`data-numkey-name`) A plain form POST submits what the field displays — `1,234,567` — which every @@ -221,6 +242,7 @@ const [amount, setAmount] = useState('') | `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만"`) | +| `fromKorean(text)` | Korean shorthand → canonical (`"3만5천"` → `"35000"`) | ### DOM @@ -240,8 +262,7 @@ 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: 만/억 shorthand parsing (`3만5천` → `35000`), smart - separator-skipping deletion. +- Roadmap: smart separator-skipping deletion. ## License diff --git a/package.json b/package.json index 6a27781..627f7ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/numkey", - "version": "0.2.0", + "version": "0.3.0", "publishConfig": { "access": "public" }, diff --git a/site/index.html b/site/index.html index 90bccd7..c5f63ed 100644 --- a/site/index.html +++ b/site/index.html @@ -102,6 +102,14 @@

Korean amount reading / 한글 금액 병기 — 은행 UI "원" here comes from CSS ::after. Also a pure function: toKorean('927483041001')9,274억 8,304만 1,001.

+ + + +

+ A Korean draft is left alone while typing (no fight with the IME); blur + converts it — 3만5천35,000. The reading and any + data-numkey-name hidden field see the parsed value even mid-draft. +

diff --git a/src/core.ts b/src/core.ts index 285ea95..e73d248 100644 --- a/src/core.ts +++ b/src/core.ts @@ -29,6 +29,13 @@ export interface NumkeyOptions { * `separator`/`decimalPoint` win over the locale. */ locale?: string + /** + * DOM layer only: accept Korean-shorthand entry ("3만5천"). While the + * value contains Korean number characters the field is left alone (no + * live reformat — that would fight the IME), and blur converts it via + * `fromKorean` ("3만5천" → "35,000"). Default false. + */ + koreanEntry?: boolean } type Resolved = Required @@ -82,7 +89,8 @@ export function resolveOptions(opts?: NumkeyOptions): Resolved { group: opts?.group ?? 3, separator: separator ?? ',', decimalPoint: decimalPoint ?? '.', - locale: opts?.locale ?? '' + locale: opts?.locale ?? '', + koreanEntry: opts?.koreanEntry ?? false } } diff --git a/src/dom.test.ts b/src/dom.test.ts index 4535253..db4e16c 100644 --- a/src/dom.test.ts +++ b/src/dom.test.ts @@ -223,6 +223,70 @@ describe('data-numkey-name — hidden canonical sync for form POSTs', () => { }) }) +describe('data-numkey-korean-entry — shorthand entry', () => { + it('leaves a Korean draft alone while typing, converts on blur', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + feed(el, '3만5천') + expect(el.value).toBe('3만5천') // untouched — no live reformat mid-word + el.dispatchEvent(new Event('blur')) + expect(el.value).toBe('35,000') + }) + + it('plain digits still format live', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + feed(el, '1234567') + expect(el.value).toBe('1,234,567') + }) + + it('getValue and the hidden sync see the parsed value mid-draft', () => { + const el = makeInput({ + 'data-numkey': '', + 'data-numkey-korean-entry': '', + 'data-numkey-name': 'amount' + }) + bind(el) + const hidden = el.nextElementSibling as HTMLInputElement + feed(el, '1.5억') + expect(el.value).toBe('1.5억') // display: still the draft + expect(getValue(el)).toBe('150000000') + expect(hidden.value).toBe('150000000') // posts correctly even pre-blur + }) + + it('without the attribute Korean characters are stripped as before', () => { + const el = makeInput({ 'data-numkey': '' }) + bind(el) + feed(el, '3만5천') + expect(el.value).toBe('35') + }) + + it('typing 1.5억 char by char keeps the decimal until the unit arrives', () => { + // regression: the live reformat used to strip the '.' on an integer + // field before 억 was typed, silently turning 1.5억 into 15억 + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + let v = '' + for (const ch of '1.5억') { + v += ch + feed(el, v) + v = el.value // simulate the browser: next keystroke appends to what's shown + } + expect(el.value).toBe('1.5억') + el.dispatchEvent(new Event('blur')) + expect(el.value).toBe('150,000,000') + }) + + it('a bare decimal draft on an integer field truncates on blur', () => { + const el = makeInput({ 'data-numkey': '', 'data-numkey-korean-entry': '' }) + bind(el) + feed(el, '1.5') // user stopped before typing the unit + expect(el.value).toBe('1.5') // protected while it could still become 1.5억 + el.dispatchEvent(new Event('blur')) + expect(el.value).toBe('1') // integer field: fraction truncated, not "15" + }) +}) + 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 c347d82..90a086f 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -11,6 +11,9 @@ * — 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" + * — accept Korean shorthand: + * typing "3만5천" is left alone until blur, which converts it to + * "35,000" (fromKorean) * * Binding sets `inputmode` (numeric/decimal) and right-aligns the field * unless either is already set, formats any server-rendered value in place, @@ -31,7 +34,7 @@ import { parse, type NumkeyOptions } from './core' -import { toKorean } from './korean' +import { fromKorean, KOREAN_NUMBER_CHARS, toKorean } from './korean' const SELECTOR = 'input[data-numkey]' @@ -46,7 +49,8 @@ export function optionsFromElement(el: HTMLInputElement): NumkeyOptions { const locale = el.getAttribute('data-numkey-locale') const opts: NumkeyOptions = { decimals: main ? parseInt(main, 10) || 0 : 0, - negative: el.hasAttribute('data-numkey-negative') + negative: el.hasAttribute('data-numkey-negative'), + koreanEntry: el.hasAttribute('data-numkey-korean-entry') } if (group) opts.group = parseInt(group, 10) || 3 if (separator !== null) opts.separator = separator @@ -103,9 +107,41 @@ export function finalizeInput( return true } +/** + * Clamp a canonical value to the field's `decimals` budget ("1.5" → "1" on + * an integer field). Needed after `fromKorean`, which can yield fractions + * the field itself would never allow. + */ +function constrain(canonical: string, o: NumkeyOptions): string { + const point = canonical.indexOf('.') + if (point === -1) return canonical + const decimals = o.decimals ?? 0 + if (decimals <= 0) return canonical.slice(0, point) || '0' + const cut = canonical.slice(0, point + 1 + decimals) + return cut.endsWith('.') ? cut.slice(0, -1) : cut +} + +/** + * Whether a value is a Korean-shorthand draft that live reformatting must + * not touch: it contains Korean number chars, or it is a decimal draft on + * an integer field ("1." on the way to "1.5억" — stripping the point before + * the unit arrives would silently turn 1.5억 into 15억). + */ +function isKoreanDraftValue(value: string, o: NumkeyOptions): boolean { + if (!(o.koreanEntry ?? false)) return false + if (KOREAN_NUMBER_CHARS.test(value)) return true + return (o.decimals ?? 0) === 0 && /\d[..]\d*$/.test(value) +} + +/** Canonical value of a raw display value under the given options. */ +function canonicalOf(value: string, o: NumkeyOptions): string { + if (isKoreanDraftValue(value, o)) return constrain(fromKorean(value), o) + return finalize(parse(value, o)) +} + /** The canonical (unformatted, settled) value of a bound input. */ export function getValue(el: HTMLInputElement, opts?: NumkeyOptions): string { - return finalize(parse(el.value, opts ?? optionsFromElement(el))) + return canonicalOf(el.value, opts ?? optionsFromElement(el)) } /** @@ -176,19 +212,28 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void { const syncExtras = (o: NumkeyOptions): void => { if (!koreanTarget && !hidden) return - const canonical = finalize(parse(el.value, o)) + const canonical = canonicalOf(el.value, o) if (koreanTarget) koreanTarget.textContent = toKorean(canonical) if (hidden) hidden.value = canonical } - applyToInput(el, initial) - syncExtras(initial) + const settle = (o: NumkeyOptions): void => { + if (isKoreanDraftValue(el.value, o)) { + el.value = format(constrain(fromKorean(el.value), o), o) + syncExtras(o) + } else { + finalizeInput(el, o) + syncExtras(o) + } + } + + settle(initial) // server-rendered value → formatted display let composing = false const run = (): void => { const o = resolve() - applyToInput(el, o) + if (!isKoreanDraftValue(el.value, o)) applyToInput(el, o) syncExtras(o) } @@ -203,9 +248,7 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void { if (!composing) run() } const onBlur = (): void => { - const o = resolve() - finalizeInput(el, o) - syncExtras(o) + settle(resolve()) } el.addEventListener('compositionstart', onCompositionStart) diff --git a/src/index.ts b/src/index.ts index ce5a6d7..77987c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export { resolveOptions, type NumkeyOptions } from './core' -export { toKorean, type ToKoreanOptions } from './korean' +export { fromKorean, toKorean, type ToKoreanOptions } from './korean' export { applyToInput, bind, diff --git a/src/korean.test.ts b/src/korean.test.ts index 90f797a..6442959 100644 --- a/src/korean.test.ts +++ b/src/korean.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { toKorean } from './korean' +import { fromKorean, toKorean } from './korean' describe('toKorean — mixed digits-plus-units reading', () => { it('reads 만-scale amounts the way banking UIs show them', () => { @@ -45,3 +45,54 @@ describe('toKorean — mixed digits-plus-units reading', () => { expect(toKorean('10000000000000000')).toBe('1경') }) }) + +describe('fromKorean — shorthand entry → canonical', () => { + it('parses digits-plus-units shorthand', () => { + expect(fromKorean('3만5천')).toBe('35000') + expect(fromKorean('5천')).toBe('5000') + expect(fromKorean('3백5십')).toBe('350') + expect(fromKorean('1조2억')).toBe('1000200000000') + }) + + it('parses decimal units the way 부동산/주식 apps use them', () => { + expect(fromKorean('1.5억')).toBe('150000000') + expect(fromKorean('0.5만')).toBe('5000') + expect(fromKorean('2.35억')).toBe('235000000') + }) + + it('parses full Hangul digits', () => { + expect(fromKorean('삼만오천')).toBe('35000') + expect(fromKorean('삼십오만')).toBe('350000') + expect(fromKorean('천오백')).toBe('1500') + }) + + it('a bare unit means 1', () => { + expect(fromKorean('만')).toBe('10000') + expect(fromKorean('억')).toBe('100000000') + expect(fromKorean('천')).toBe('1000') + }) + + it('round-trips toKorean output', () => { + for (const v of ['35000', '927483041001', '100000001', '1000050000']) { + expect(fromKorean(toKorean(v))).toBe(v) + } + }) + + it('ignores commas, spaces, and 원; plain numbers pass through', () => { + expect(fromKorean('9,274억 8,304만 1,001')).toBe('927483041001') + expect(fromKorean('3만 5천 원')).toBe('35000') + expect(fromKorean('12,345')).toBe('12345') + }) + + it('keeps the sign and handles empty/zero', () => { + expect(fromKorean('-3만')).toBe('-30000') + expect(fromKorean('−1.5억')).toBe('-150000000') // U+2212 + expect(fromKorean('')).toBe('') + expect(fromKorean('0만')).toBe('0') + expect(fromKorean('영')).toBe('0') + }) + + it('normalizes full-width digits', () => { + expect(fromKorean('3만5천')).toBe('35000') + }) +}) diff --git a/src/korean.ts b/src/korean.ts index af3d078..2d615a2 100644 --- a/src/korean.ts +++ b/src/korean.ts @@ -14,6 +14,37 @@ import { finalize, format } from './core' const UNITS = ['', '만', '억', '조', '경', '해', '자', '양'] +const BIG: Record = { 만: 4, 억: 8, 조: 12, 경: 16 } +const SMALL: Record = { 천: 3, 백: 2, 십: 1 } +const HANGUL_DIGIT: Record = { + 영: '0', 공: '0', 일: '1', 이: '2', 삼: '3', 사: '4', + 오: '5', 육: '6', 칠: '7', 팔: '8', 구: '9' +} + +/** Chars that mark a value as Korean-shorthand entry (used by the DOM layer). */ +export const KOREAN_NUMBER_CHARS = /[만억조경천백십영공일이삼사오육칠팔구]/ + +/** Digits + optional small units within one 만-group: "3십5" → 35, "5천" → 5000. */ +function parseSmallUnits(group: string): number { + let result = 0 + let digits = '' + for (const ch of group) { + if ((ch >= '0' && ch <= '9') || ch === '.') { + digits += ch + } else if (ch in SMALL) { + const n = digits === '' ? 1 : parseFloat(digits) + if (!Number.isNaN(n)) result += n * 10 ** (SMALL[ch] as number) + digits = '' + } + // anything else is noise — dropped, same policy as parse() + } + if (digits !== '') { + const n = parseFloat(digits) + if (!Number.isNaN(n)) result += n + } + return result +} + export interface ToKoreanOptions { /** Space between unit groups ("150만 5,000" vs "150만5,000"). Default true. */ spacing?: boolean @@ -45,3 +76,45 @@ export function toKorean(canonical: string, opts?: ToKoreanOptions): string { } return (neg ? '-' : '') + parts.join(opts?.spacing === false ? '' : ' ') } + +/** + * Korean-shorthand amount → canonical numeric string. The inverse direction + * of `toKorean`, accepting what people actually type into 부동산/주식 apps: + * + * fromKorean('3만5천') // "35000" + * fromKorean('1.5억') // "150000000" + * fromKorean('삼십오만') // "350000" + * fromKorean('9,274억 8,304만 1,001') // "927483041001" + * + * Grammar: optional sign, then descending `[digits][.digits]?` × unit + * segments (만/억/조/경, sub-units 천/백/십, full Hangul digits 일이삼…), + * then optional trailing plain digits. Commas, spaces, and "원" are noise. + * A bare unit means 1 ("만" → 10000). Plain numbers pass through + * ("12,345" → "12345"). Computed with Number arithmetic — exact through the + * money range (안전 한도 ~9,000조); returns '' for empty/unparseable input. + */ +export function fromKorean(text: string): string { + let s = text + .replace(/[0-9]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 48)) + .replace(/[\s,,원]/g, '') + .replace(/[영공일이삼사오육칠팔구]/g, (ch) => HANGUL_DIGIT[ch] as string) + const neg = /^[-−-﹣]/.test(s) + if (neg) s = s.slice(1) + if (s === '') return '' + + let total = 0 + let group = '' + for (const ch of s) { + if (ch in BIG) { + total += (group === '' ? 1 : parseSmallUnits(group)) * 10 ** (BIG[ch] as number) + group = '' + } else { + group += ch + } + } + if (group !== '') total += parseSmallUnits(group) + + if (!Number.isFinite(total)) return '' + if (total === 0) return '0' + return (neg ? '-' : '') + String(total) +}