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)
+}