From ff7feb1a45065d8b10560ebf37caeb2545f9837e Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Fri, 17 Jul 2026 22:33:10 +0900 Subject: [PATCH] feat: smart deletion across separators + min/max bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adjustDeleteCaret: Backspace/Delete next to a group separator now deletes the adjacent digit in one keystroke. Previously the keystroke deleted the separator, the reformat put it back, and the caret just drifted — the digit died on the second press. The keydown handler moves the caret past the separator so the browser's default action lands on the digit; wired automatically in bind() and both components, exported for custom use. Suppressed mid-IME-composition. - clamp / min / max (data-numkey-min / data-numkey-max): bounds applied on blur only — clamping mid-keystroke would make 50 untypeable in a min-10 field. Applies to the Korean-shorthand settle path too (1.5억 with max 100만 clamps to 1,000,000). Transient states pass through. - version 0.4.0, bilingual CHANGELOG, README rows; roadmap now down to Indian lakh grouping 86 tests (11 new). Real-browser check: keydown at '1,|234,567' moves the caret past the comma and the default deletion yields '234,567' in one keystroke; min-10 field accepts a mid-typing '5' and settles to 10 on blur. --- CHANGELOG.md | 13 ++++++++ README.ko.md | 9 +++--- README.md | 9 +++--- package.json | 2 +- src/core.test.ts | 24 +++++++++++++++ src/core.ts | 28 ++++++++++++++++- src/dom.test.ts | 80 +++++++++++++++++++++++++++++++++++++++++++++++- src/dom.ts | 51 +++++++++++++++++++++++++++--- src/index.ts | 2 ++ src/react.ts | 13 ++++++-- src/vue.ts | 11 +++++-- 11 files changed, 221 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d754bfb..ee3ae6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.ko.md b/README.ko.md index f0a44f7..a5b1ead 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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 시에만 적용 (입력 중 간섭 없음) | ### 로케일 표시 (옵트인) @@ -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 (순수 함수) @@ -256,10 +258,9 @@ const [amount, setAmount] = useState('') - 유럽식 포맷도 옵션으로 지원: `{ separator: '.', decimalPoint: ',' }`이면 `1.234.567,89`로 표시되고 정식 값은 `"1234567.89"`로 유지됩니다. -- 구분자 바로 뒤에서 백스페이스를 누르면 커서가 구분자를 지나칩니다 (숫자는 - 다음 백스페이스에서 삭제) — 주요 마스킹 라이브러리들과 같은 동작입니다. - 구분자 건너뛰기 삭제는 로드맵에 있습니다. -- 로드맵: 구분자 건너뛰기 삭제. +- 구분자 옆 백스페이스/Delete는 한 번에 인접한 숫자를 지웁니다 (구분자는 + 건너뛰고, 재포맷이 나머지를 처리). +- 로드맵: 인도식 lakh 그룹핑 (`12,34,567` — 비균일 그룹 크기). ## License diff --git a/README.md b/README.md index 5fd1231..58d00d5 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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) @@ -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 diff --git a/package.json b/package.json index 627f7ba..c85c3cc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/numkey", - "version": "0.3.0", + "version": "0.4.0", "publishConfig": { "access": "public" }, diff --git a/src/core.test.ts b/src/core.test.ts index ed8f754..3f9b223 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { caretIndex, + clamp, countSignificant, finalize, format, @@ -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) diff --git a/src/core.ts b/src/core.ts index e73d248..8018358 100644 --- a/src/core.ts +++ b/src/core.ts @@ -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 @@ -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) diff --git a/src/dom.test.ts b/src/dom.test.ts index db4e16c..1997d0a 100644 --- a/src/dom.test.ts +++ b/src/dom.test.ts @@ -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 = {}): HTMLInputElement { const el = document.createElement('input') @@ -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': '' }) diff --git a/src/dom.ts b/src/dom.ts index 90a086f..2dc9c73 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -28,10 +28,12 @@ */ import { caretIndex, + clamp, countSignificant, finalize, format, parse, + resolveOptions, type NumkeyOptions } from './core' import { fromKorean, KOREAN_NUMBER_CHARS, toKorean } from './korean' @@ -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'), @@ -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 } @@ -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 @@ -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 @@ -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) diff --git a/src/index.ts b/src/index.ts index 77987c3..a077af1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { caretIndex, + clamp, countSignificant, finalize, format, @@ -10,6 +11,7 @@ export { } from './core' export { fromKorean, toKorean, type ToKoreanOptions } from './korean' export { + adjustDeleteCaret, applyToInput, bind, createRefBinder, diff --git a/src/react.ts b/src/react.ts index f04b089..1387062 100644 --- a/src/react.ts +++ b/src/react.ts @@ -24,18 +24,20 @@ import { type CSSProperties, type FocusEvent, type InputHTMLAttributes, + type KeyboardEvent, type ReactElement, type Ref } from 'react' import { caretIndex, + clamp, countSignificant, finalize, format, parse, type NumkeyOptions } from './core' -import { applyToInput, createRefBinder } from './dom' +import { adjustDeleteCaret, applyToInput, createRefBinder } from './dom' export function useNumkey( opts?: NumkeyOptions @@ -50,6 +52,7 @@ type OverriddenHandlers = | 'defaultValue' | 'onChange' | 'onBlur' + | 'onKeyDown' | 'onCompositionStart' | 'onCompositionEnd' @@ -65,6 +68,7 @@ export type NumkeyInputProps = { onValueChange?: (canonical: string) => void onChange?: (e: ChangeEvent) => void onBlur?: (e: FocusEvent) => void + onKeyDown?: (e: KeyboardEvent) => void onCompositionStart?: (e: CompositionEvent) => void onCompositionEnd?: (e: CompositionEvent) => void } & Omit, OverriddenHandlers> @@ -92,6 +96,7 @@ export const NumkeyInput = forwardRef(function NumkeyInput( onValueChange, onChange, onBlur, + onKeyDown, onCompositionStart, onCompositionEnd, style, @@ -141,9 +146,13 @@ export const NumkeyInput = forwardRef(function NumkeyInput( onValueChange?.(parse(e.currentTarget.value, opts)) onChange?.(e) }, + onKeyDown: (e: KeyboardEvent) => { + if (!composing.current) adjustDeleteCaret(e.currentTarget, e.key, opts) + onKeyDown?.(e) + }, onBlur: (e: FocusEvent) => { const el = e.currentTarget - const canonical = finalize(parse(el.value, opts)) + const canonical = clamp(finalize(parse(el.value, opts)), opts) const display = format(canonical, opts) if (display !== el.value) { setValueNative(el, display) diff --git a/src/vue.ts b/src/vue.ts index 4f5c20b..8789dd1 100644 --- a/src/vue.ts +++ b/src/vue.ts @@ -17,8 +17,8 @@ * */ import { defineComponent, h, type Directive, type PropType } from 'vue' -import { finalize, format, parse, type NumkeyOptions } from './core' -import { applyToInput, bind, finalizeInput } from './dom' +import { clamp, finalize, format, parse, type NumkeyOptions } from './core' +import { adjustDeleteCaret, applyToInput, bind, finalizeInput } from './dom' type DirectiveValue = NumkeyOptions | number | undefined @@ -91,11 +91,16 @@ export const NumkeyInput = defineComponent({ onInput: (e: Event) => { if (!composing) formatAndEmit(e) }, + onKeydown: (e: KeyboardEvent) => { + if (!composing) { + adjustDeleteCaret(e.target as HTMLInputElement, e.key, opts()) + } + }, onBlur: (e: Event) => { const el = e.target as HTMLInputElement const o = opts() finalizeInput(el, o) - emit('update:modelValue', finalize(parse(el.value, o))) + emit('update:modelValue', clamp(finalize(parse(el.value, o)), o)) } }) }