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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.4.0 (2026-07-17)

- **Smart deletion** — Backspace/Delete next to a group separator now takes
out the adjacent digit in one keystroke instead of bouncing off the
separator. Automatic in the DOM layer and both components; also exported
as `adjustDeleteCaret(el, key, opts?)` for custom wiring. / 구분자 옆
백스페이스/Delete가 한 번에 숫자를 지웁니다 (기존: 두 번).
- **`min` / `max`** (`data-numkey-min` / `data-numkey-max`) — bounds applied
on blur only, never mid-keystroke (you can still type 50 in a min-10
field). Applies to Korean shorthand too (`1.5억` with max 100만 → 100만).
Exported as `clamp(canonical, opts?)`. / blur 시에만 적용되는 최소/최대 —
입력 도중에는 간섭하지 않습니다.

## 0.3.0 (2026-07-17)

- **`fromKorean(text)`** — Korean-shorthand amount parsing, the inverse of
Expand Down
9 changes: 5 additions & 4 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ numkey는 그 인풋을 한 번에 끝냅니다:
| `data-numkey-korean` | 실시간 한글 금액 병기 ("150만") — 아래 참조 |
| `data-numkey-korean-entry` | 만/억 축약 입력 허용 ("3만5천" → blur → "35,000") — 아래 참조 |
| `data-numkey-name="amount"` | 정식 값을 전송하는 hidden 인풋 — 아래 참조 |
| `data-numkey-min` / `data-numkey-max` | 최소/최대 — blur 시에만 적용 (입력 중 간섭 없음) |

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

Expand Down Expand Up @@ -230,6 +231,7 @@ const [amount, setAmount] = useState('')
| `separator` | `","` | 표시용 그룹 구분자 |
| `decimalPoint` | `"."` | 표시용 소수점 (정식 값은 항상 `.`) |
| `locale` | — | **옵트인**: `Intl`로 `separator`/`decimalPoint` 유도 — `"auto"`(브라우저 언어) 또는 BCP 47 태그. 지정하지 않으면 방문자 브라우저와 무관하게 표시가 고정됩니다 (업무 폼의 기본 요구). 명시한 `separator`/`decimalPoint`가 우선. |
| `min` / `max` | — | 최소/최대 — blur 시에만 적용. 입력 도중 클램핑하면 min이 10인 필드에 50을 칠 수 없게 되니까요 |

### Core (순수 함수)

Expand All @@ -256,10 +258,9 @@ const [amount, setAmount] = useState('')

- 유럽식 포맷도 옵션으로 지원: `{ separator: '.', decimalPoint: ',' }`이면
`1.234.567,89`로 표시되고 정식 값은 `"1234567.89"`로 유지됩니다.
- 구분자 바로 뒤에서 백스페이스를 누르면 커서가 구분자를 지나칩니다 (숫자는
다음 백스페이스에서 삭제) — 주요 마스킹 라이브러리들과 같은 동작입니다.
구분자 건너뛰기 삭제는 로드맵에 있습니다.
- 로드맵: 구분자 건너뛰기 삭제.
- 구분자 옆 백스페이스/Delete는 한 번에 인접한 숫자를 지웁니다 (구분자는
건너뛰고, 재포맷이 나머지를 처리).
- 로드맵: 인도식 lakh 그룹핑 (`12,34,567` — 비균일 그룹 크기).

## License

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ it does nothing:
| `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 |
| `data-numkey-min` / `data-numkey-max` | bounds, applied on blur only (typing is never interrupted) |

### Locale-aware display (opt-in)

Expand Down Expand Up @@ -233,6 +234,7 @@ const [amount, setAmount] = useState('')
| `separator` | `","` | group separator in the display |
| `decimalPoint` | `"."` | decimal mark in the display (canonical always uses `.`) |
| `locale` | — | **opt-in**: derive `separator`/`decimalPoint` via `Intl` — `"auto"` (browser language) or a BCP 47 tag. Without it the display is deterministic no matter the visitor's browser, which is what business forms usually need. Explicit `separator`/`decimalPoint` win. |
| `min` / `max` | — | bounds, applied on blur only — clamping mid-keystroke would make 50 untypeable in a min-10 field |

### Core (pure functions)

Expand All @@ -259,10 +261,9 @@ const [amount, setAmount] = useState('')

- European formats work via options: `{ separator: '.', decimalPoint: ',' }`
displays `1.234.567,89` while the canonical value stays `"1234567.89"`.
- 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: smart separator-skipping deletion.
- Backspace/Delete next to a group separator deletes the adjacent digit in
one keystroke (the separator is skipped, and the reformat handles the rest).
- Roadmap: Indian lakh grouping (`12,34,567` — non-uniform group sizes).

## 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.3.0",
"version": "0.4.0",
"publishConfig": {
"access": "public"
},
Expand Down
24 changes: 24 additions & 0 deletions src/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
caretIndex,
clamp,
countSignificant,
finalize,
format,
Expand Down Expand Up @@ -140,6 +141,29 @@ describe('finalize — settle transient states', () => {
})
})

describe('clamp — min/max on settled values', () => {
it('clamps to the bounds and returns them canonically', () => {
expect(clamp('5', { min: 10 })).toBe('10')
expect(clamp('500', { max: 100 })).toBe('100')
expect(clamp('50', { min: 10, max: 100 })).toBe('50')
expect(clamp('-5', { negative: true, min: 0 })).toBe('0')
})

it('accepts string bounds and decimal values', () => {
expect(clamp('99.99', { decimals: 2, max: '50.5' })).toBe('50.5')
})

it('passes transient and empty states through untouched', () => {
expect(clamp('', { min: 10 })).toBe('')
expect(clamp('-', { negative: true, min: 10 })).toBe('-')
expect(clamp('123.', { decimals: 2, min: 500 })).toBe('123.')
})

it('without bounds it is the identity', () => {
expect(clamp('123456789')).toBe('123456789')
})
})

describe('caret math', () => {
it('counts only chars that survive parse', () => {
expect(countSignificant('1,234')).toBe(4)
Expand Down
28 changes: 27 additions & 1 deletion src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface NumkeyOptions {
* `fromKorean` ("3만5천" → "35,000"). Default false.
*/
koreanEntry?: boolean
/**
* Lower/upper bounds, applied on blur only — clamping mid-keystroke is
* hostile (you cannot type 50 in a min-10 field if 5 gets rejected).
* Compared numerically; exact through the IEEE 754 safe-integer range.
*/
min?: string | number
max?: string | number
}

type Resolved = Required<NumkeyOptions>
Expand Down Expand Up @@ -90,10 +97,29 @@ export function resolveOptions(opts?: NumkeyOptions): Resolved {
separator: separator ?? ',',
decimalPoint: decimalPoint ?? '.',
locale: opts?.locale ?? '',
koreanEntry: opts?.koreanEntry ?? false
koreanEntry: opts?.koreanEntry ?? false,
min: opts?.min ?? '',
max: opts?.max ?? ''
}
}

/**
* Clamp a settled canonical value to `min`/`max`. Transient states and
* empty values pass through untouched; bounds are returned in canonical
* form (`clamp('5', { min: 10 })` → `"10"`).
*/
export function clamp(canonical: string, opts?: NumkeyOptions): string {
if (canonical === '' || canonical === '-' || canonical.endsWith('.')) {
return canonical
}
const o = resolveOptions(opts)
const n = Number(canonical)
if (Number.isNaN(n)) return canonical
if (o.min !== '' && n < Number(o.min)) return String(Number(o.min))
if (o.max !== '' && n > Number(o.max)) return String(Number(o.max))
return canonical
}

/** Full-width digits/signs (Korean and Japanese IMEs emit these) → ASCII. */
function normalizeChar(ch: string): string {
const code = ch.charCodeAt(0)
Expand Down
80 changes: 79 additions & 1 deletion src/dom.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { applyToInput, bind, getValue, observe, setValue } from './dom'
import {
adjustDeleteCaret,
applyToInput,
bind,
getValue,
observe,
setValue
} from './dom'

function makeInput(attrs: Record<string, string> = {}): HTMLInputElement {
const el = document.createElement('input')
Expand Down Expand Up @@ -287,6 +294,77 @@ describe('data-numkey-korean-entry — shorthand entry', () => {
})
})

describe('adjustDeleteCaret — one-keystroke deletion across separators', () => {
it('Backspace right after a separator steps past it', () => {
const el = makeInput({ 'data-numkey': '' })
el.value = '1,234'
el.setSelectionRange(2, 2) // "1,|234"
adjustDeleteCaret(el, 'Backspace')
expect(el.selectionStart).toBe(1) // default action now deletes the 1
})

it('Delete right before a separator steps past it', () => {
const el = makeInput({ 'data-numkey': '' })
el.value = '1,234'
el.setSelectionRange(1, 1) // "1|,234"
adjustDeleteCaret(el, 'Delete')
expect(el.selectionStart).toBe(2) // default action now deletes the 2
})

it('leaves range selections and non-separator positions alone', () => {
const el = makeInput({ 'data-numkey': '' })
el.value = '1,234'
el.setSelectionRange(0, 3)
adjustDeleteCaret(el, 'Backspace')
expect(el.selectionStart).toBe(0)
expect(el.selectionEnd).toBe(3)

el.setSelectionRange(4, 4) // "1,23|4" — plain digit behind
adjustDeleteCaret(el, 'Backspace')
expect(el.selectionStart).toBe(4)
})

it('the bound keydown handler applies it automatically', () => {
const el = makeInput({ 'data-numkey': '' })
bind(el)
el.value = '1,234'
el.setSelectionRange(2, 2)
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace' }))
expect(el.selectionStart).toBe(1)
})
})

describe('data-numkey-min / data-numkey-max — clamped on blur only', () => {
it('does not interfere while typing, clamps on blur', () => {
const el = makeInput({ 'data-numkey': '', 'data-numkey-min': '10' })
bind(el)
feed(el, '5') // on the way to 50 — must not be rejected mid-typing
expect(el.value).toBe('5')
el.dispatchEvent(new Event('blur'))
expect(el.value).toBe('10')
})

it('clamps the maximum with grouping intact', () => {
const el = makeInput({ 'data-numkey': '', 'data-numkey-max': '100000' })
bind(el)
feed(el, '9999999')
el.dispatchEvent(new Event('blur'))
expect(el.value).toBe('100,000')
})

it('clamps Korean shorthand on settle too', () => {
const el = makeInput({
'data-numkey': '',
'data-numkey-korean-entry': '',
'data-numkey-max': '1000000'
})
bind(el)
feed(el, '1.5억')
el.dispatchEvent(new Event('blur'))
expect(el.value).toBe('1,000,000')
})
})

describe('observe — auto-init', () => {
it('binds existing and later-added [data-numkey] inputs', async () => {
const existing = makeInput({ 'data-numkey': '' })
Expand Down
51 changes: 46 additions & 5 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@
*/
import {
caretIndex,
clamp,
countSignificant,
finalize,
format,
parse,
resolveOptions,
type NumkeyOptions
} from './core'
import { fromKorean, KOREAN_NUMBER_CHARS, toKorean } from './korean'
Expand All @@ -47,6 +49,8 @@ export function optionsFromElement(el: HTMLInputElement): NumkeyOptions {
const separator = el.getAttribute('data-numkey-separator')
const decimalPoint = el.getAttribute('data-numkey-point')
const locale = el.getAttribute('data-numkey-locale')
const min = el.getAttribute('data-numkey-min')
const max = el.getAttribute('data-numkey-max')
const opts: NumkeyOptions = {
decimals: main ? parseInt(main, 10) || 0 : 0,
negative: el.hasAttribute('data-numkey-negative'),
Expand All @@ -56,6 +60,8 @@ export function optionsFromElement(el: HTMLInputElement): NumkeyOptions {
if (separator !== null) opts.separator = separator
if (decimalPoint !== null) opts.decimalPoint = decimalPoint
if (locale) opts.locale = locale
if (min !== null) opts.min = min
if (max !== null) opts.max = max
return opts
}

Expand Down Expand Up @@ -96,17 +102,48 @@ export function applyToInput(
return true
}

/** Settle transient states and reformat — what blur runs. */
/** Settle transient states, apply min/max, and reformat — what blur runs. */
export function finalizeInput(
el: HTMLInputElement,
opts?: NumkeyOptions
): boolean {
const display = format(finalize(parse(el.value, opts)), opts)
const display = format(clamp(finalize(parse(el.value, opts)), opts), opts)
if (display === el.value) return false
el.value = display
return true
}

/**
* Caret adjustment that makes Backspace/Delete take out the digit next to a
* separator in ONE keystroke. Without it, backspacing at "1,|234" deletes
* the comma, the reformat puts it back, and the caret just drifts left —
* the digit dies on the second press. Call from keydown BEFORE the default
* action: the caret is moved past the separator so the default deletes the
* digit, and the input event reformats as usual.
*/
export function adjustDeleteCaret(
el: HTMLInputElement,
key: string,
opts?: NumkeyOptions
): void {
if (key !== 'Backspace' && key !== 'Delete') return
const o = resolveOptions(opts ?? optionsFromElement(el))
if (o.separator.length !== 1) return
const start = getCaret(el)
let end: number | null = null
try {
end = el.selectionEnd
} catch {
/* no selection API */
}
if (start === null || start !== end) return // range selections delete as-is
if (key === 'Backspace' && start > 0 && el.value[start - 1] === o.separator) {
setCaret(el, start - 1)
} else if (key === 'Delete' && el.value[start] === o.separator) {
setCaret(el, start + 1)
}
}

/**
* Clamp a canonical value to the field's `decimals` budget ("1.5" → "1" on
* an integer field). Needed after `fromKorean`, which can yield fractions
Expand Down Expand Up @@ -219,12 +256,11 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void {

const settle = (o: NumkeyOptions): void => {
if (isKoreanDraftValue(el.value, o)) {
el.value = format(constrain(fromKorean(el.value), o), o)
syncExtras(o)
el.value = format(clamp(constrain(fromKorean(el.value), o), o), o)
} else {
finalizeInput(el, o)
syncExtras(o)
}
syncExtras(o)
}

settle(initial) // server-rendered value → formatted display
Expand All @@ -250,17 +286,22 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void {
const onBlur = (): void => {
settle(resolve())
}
const onKeyDown = (e: KeyboardEvent): void => {
if (!composing) adjustDeleteCaret(el, e.key, resolve())
}

el.addEventListener('compositionstart', onCompositionStart)
el.addEventListener('compositionend', onCompositionEnd)
el.addEventListener('input', onInput)
el.addEventListener('blur', onBlur)
el.addEventListener('keydown', onKeyDown)

const unbind = (): void => {
el.removeEventListener('compositionstart', onCompositionStart)
el.removeEventListener('compositionend', onCompositionEnd)
el.removeEventListener('input', onInput)
el.removeEventListener('blur', onBlur)
el.removeEventListener('keydown', onKeyDown)
if (koreanCreated) koreanTarget?.remove()
hidden?.remove()
unbinders.delete(el)
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
caretIndex,
clamp,
countSignificant,
finalize,
format,
Expand All @@ -10,6 +11,7 @@ export {
} from './core'
export { fromKorean, toKorean, type ToKoreanOptions } from './korean'
export {
adjustDeleteCaret,
applyToInput,
bind,
createRefBinder,
Expand Down
Loading