Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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. / 한국 특화 릴리스.
Expand Down
24 changes: 23 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 인풋 — 아래 참조 |

### 로케일 표시 (옵트인)
Expand Down Expand Up @@ -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
<input data-numkey data-numkey-korean-entry>
<!-- 3만5천 입력 중에는 건드리지 않고 (IME 조합과 안 싸움),
blur에 35,000으로 변환 -->
```

`getValue`와 `data-numkey-name` hidden 동기화(아래)는 초안이 화면에 있는
동안에도 파싱된 값을 봅니다.

### 정식 값으로 전송하기 (`data-numkey-name`)

일반 폼 POST는 필드에 보이는 그대로 — `1,234,567` — 전송하고, 서버는 매번
Expand Down Expand Up @@ -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

Expand All @@ -237,7 +259,7 @@ const [amount, setAmount] = useState('')
- 구분자 바로 뒤에서 백스페이스를 누르면 커서가 구분자를 지나칩니다 (숫자는
다음 백스페이스에서 삭제) — 주요 마스킹 라이브러리들과 같은 동작입니다.
구분자 건너뛰기 삭제는 로드맵에 있습니다.
- 로드맵: 만/억 축약 파싱 (`3만5천` → `35000`), 구분자 건너뛰기 삭제.
- 로드맵: 구분자 건너뛰기 삭제.

## License

Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
<input data-numkey data-numkey-korean-entry>
<!-- typing 3만5천 is left alone (no live reformat fighting the IME);
blur converts it to 35,000 -->
```

`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
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@devslab/numkey",
"version": "0.2.0",
"version": "0.3.0",
"publishConfig": {
"access": "public"
},
Expand Down
8 changes: 8 additions & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ <h2>Korean amount reading <span class="ko">/ 한글 금액 병기 — 은행 UI
"원" here comes from CSS <code>::after</code>. Also a pure function:
<code>toKorean('927483041001')</code> → <code>9,274억 8,304만 1,001</code>.
</p>

<label>Shorthand entry — try typing <code>3만5천</code> or <code>1.5억</code>, then leave the field · <code>data-numkey-korean-entry</code></label>
<input type="text" data-numkey data-numkey-korean-entry data-numkey-korean placeholder="3만5천">
<p style="font-size:0.9rem; margin-bottom:0">
A Korean draft is left alone while typing (no fight with the IME); blur
converts it — <code>3만5천</code> → <code>35,000</code>. The reading and any
<code>data-numkey-name</code> hidden field see the parsed value even mid-draft.
</p>
</div>

<div class="card">
Expand Down
10 changes: 9 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NumkeyOptions>
Expand Down Expand Up @@ -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
}
}

Expand Down
64 changes: 64 additions & 0 deletions src/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '' })
Expand Down
63 changes: 53 additions & 10 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
* <input data-numkey data-numkey-name="amount"> — 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"
* <input data-numkey data-numkey-korean-entry> — 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,
Expand All @@ -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]'

Expand All @@ -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
Expand Down Expand Up @@ -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))
}

/**
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading