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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<span class="numkey-korean">` 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. / 최초 릴리스.
Expand Down
50 changes: 47 additions & 3 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ numkey는 그 인풋을 한 번에 끝냅니다:
| `data-numkey-separator=" "` | 그룹 구분자 (기본 `,`) |
| `data-numkey-point=","` | 필드에 표시되는 소수점 (기본 `.`) |
| `data-numkey-locale` | 로케일에서 구분자 유도 — 아래 참조 |
| `data-numkey-korean` | 실시간 한글 금액 병기 ("150만") — 아래 참조 |
| `data-numkey-name="amount"` | 정식 값을 전송하는 hidden 인풋 — 아래 참조 |

### 로케일 표시 (옵트인)

Expand All @@ -100,7 +102,49 @@ numkey는 그 인풋을 한 번에 끝냅니다:
로케일이 바꾸는 건 **그리는 방식뿐**입니다. 정식 값은 항상
`"1234567.89"` — 세 경우 모두 `numkey.getValue(el)`이 같은 문자열을
돌려줍니다. 일반 폼 POST는 *표시 값*을 전송하므로, 로케일을 쓰는 폼은
제출 전에 `getValue`를 hidden 필드에 담거나 서버에서 정규화하세요.
`data-numkey-name`(아래)으로 전송하거나 서버에서 정규화하세요.

### 한글 금액 병기

은행·핀테크 UI가 금액 필드 옆에 그리는 "150만" 힌트 — 프로젝트마다
손으로 다시 만드는 바로 그것:

```html
<input data-numkey data-numkey-korean>
<!-- 1500000 입력 시:
<input value="1,500,000"> <span class="numkey-korean">150만</span> -->

<input data-numkey data-numkey-korean="#my-hint"> <!-- 기존 요소 사용 -->
```

속성이 빈 값이면 인풋 바로 뒤에 `<span class="numkey-korean">`을 생성합니다
(무스타일 — "원"은 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
<form method="post">
<!-- 보이는 인풋에는 name이 없고, hidden이 amount=1234567을 전송 -->
<input data-numkey data-numkey-name="amount" value="1234567">
</form>
```

hidden 값은 입력 중간 상태에서도 정리된 상태를 유지하므로 (`1,234.`는
`1234`로 전송), 어느 순간 제출되든 서버는 깨끗한 숫자를 받습니다.

> `type="text"` 인풋을 쓰세요. numkey가 `inputmode`를 설정해 모바일에서 숫자
> 키패드가 뜹니다. `type="number"`는 커서 API가 없어 포맷팅과 충돌합니다.
Expand Down Expand Up @@ -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

Expand All @@ -192,8 +237,7 @@ const [amount, setAmount] = useState('')
- 구분자 바로 뒤에서 백스페이스를 누르면 커서가 구분자를 지나칩니다 (숫자는
다음 백스페이스에서 삭제) — 주요 마스킹 라이브러리들과 같은 동작입니다.
구분자 건너뛰기 삭제는 로드맵에 있습니다.
- 로드맵: 한글 금액 병기 (`1500000` → “150만”), 만/억 축약 파싱
(`3만5천` → `35000`), 클래식 폼 전송용 hidden 필드 정식 값 동기화.
- 로드맵: 만/억 축약 파싱 (`3만5천` → `35000`), 구분자 건너뛰기 삭제.

## License

Expand Down
53 changes: 49 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
<input data-numkey data-numkey-korean>
<!-- typing 1500000 renders:
<input value="1,500,000"> <span class="numkey-korean">150만</span> -->

<input data-numkey data-numkey-korean="#my-hint"> <!-- use your own element -->
```

An empty attribute generates a `<span class="numkey-korean">` 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
<form method="post">
<!-- the visible input has NO name; the hidden one posts amount=1234567 -->
<input data-numkey data-numkey-name="amount" value="1234567">
</form>
```

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

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

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.1.0",
"version": "0.2.0",
"publishConfig": {
"access": "public"
},
Expand Down
33 changes: 33 additions & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +92,26 @@ <h2>Try it — everything below is just markup <span class="ko">/ 전부 마크
<input type="text" data-numkey placeholder="paste here">
</div>

<div class="card">
<h2>Korean amount reading <span class="ko">/ 한글 금액 병기 — 은행 UI의 "150만"을 마크업만으로</span></h2>

<label>Type an amount · <code>&lt;input data-numkey data-numkey-korean&gt;</code></label>
<input type="text" data-numkey data-numkey-korean value="1500000">
<p style="font-size:0.9rem; margin-bottom:0">
The generated <code>&lt;span class="numkey-korean"&gt;</code> is unstyled — the
"원" here comes from CSS <code>::after</code>. Also a pure function:
<code>toKorean('927483041001')</code> → <code>9,274억 8,304만 1,001</code>.
</p>
</div>

<div class="card">
<h2>Form POST, solved <span class="ko">/ 폼 전송은 hidden이 정식 값으로 — 서버 콤마 제거 불필요</span></h2>

<label><code>&lt;input data-numkey data-numkey-name="amount"&gt;</code></label>
<input type="text" id="posted" data-numkey data-numkey-name="amount" value="1234567">
<div class="canonical">the hidden input will POST: <code id="posted-out"></code></div>
</div>

<div class="card">
<h2>Locale (opt-in) <span class="ko">/ 로케일은 옵트인 — 기본값은 브라우저와 무관하게 고정</span></h2>
<p style="font-size:0.9rem; margin-top:0">
Expand Down Expand Up @@ -158,6 +180,17 @@ <h2>No build step <span class="ko">/ 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()
</script>
</body>
</html>
60 changes: 60 additions & 0 deletions src/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': '' })
Expand Down
Loading