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

## 0.5.0 (2026-08-08)

### Added

- **Svelte adapter** (`@devslab/numkey/svelte`) — `use:numkey` action taking
an options object, a decimals shorthand (`use:numkey={2}`), or nothing
(options from the `data-numkey*` attributes). `bind:value` works: the
action binds with `resync`, so after a reformat it re-dispatches `input`
and the binding picks up the formatted display. `onValue` reports the
CANONICAL value — the action's counterpart to React's `onValueChange`.
Imports nothing from `svelte`, so there is no peer dependency.
**Svelte 어댑터** — `use:numkey` 액션. `bind:value`(표시 값) 동작,
`onValue`로 정식 값 수신. `svelte` import 없음 — peer dependency 없음.
- **Solid adapter** (`@devslab/numkey/solid`) — `use:numkey` directive
reactive to a signal, plus a `useNumkey` ref factory. No resync needed:
Solid delegates `input` at the document level, so `onInput` already reads
the formatted value. `solid-js` is an optional peer dependency.
**Solid 어댑터** — 시그널에 반응하는 `use:numkey` 디렉티브 + `useNumkey`
ref 팩토리. `solid-js`는 optional peer.
- **`bind(el, opts, { resync })`** — opt-in re-dispatch of `input` after a
reformat changes the value, for frameworks whose own listeners run before
the binding (what the Svelte action uses). Loop-safe because reformatting
is idempotent. / **`bind` `resync` 옵션** — 포맷 후 `input` 재발행(루프 안전).

## 0.4.2 (2026-08-08)

### Fixed
Expand Down
52 changes: 51 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,39 @@ const [amount, setAmount] = useState('')
<input ref={useNumkey({ decimals: 2 })} defaultValue="1234567" />
```

## Svelte

액션입니다 — `svelte`에서 아무것도 import하지 않아 peer dependency 자체가
없습니다. `bind:value`도 동작합니다(포맷 후 바인딩을 재동기화). 다만
바인딩된 변수에는 **표시 값**이 들어가므로, **정식 값**이 필요하면
`onValue`를 쓰세요.

```svelte
<script>
import { numkey } from '@devslab/numkey/svelte'
let amount = '' // 정식 값: "1234567"
let display = '' // 표시 값: "1,234,567"
</script>

<input use:numkey={{ decimals: 2, onValue: (v) => (amount = v) }} />
<input use:numkey={2} bind:value={display} />
<input use:numkey /> <!-- 옵션은 data-numkey*에서 -->
```

## Solid

`use:` 디렉티브(시그널에 반응)와 ref 팩토리입니다. Solid는 `input`을
document 레벨에 위임하므로 `onInput`이 이미 포맷된 값을 읽습니다 —
재동기화가 필요 없습니다.

```tsx
import { numkey, useNumkey } from '@devslab/numkey/solid'

const [amount, setAmount] = createSignal('') // 정식 값
<input use:numkey={{ decimals: 2, onValue: setAmount }} />
<input ref={useNumkey({ decimals: 2 })} value="1234567" />
```

## API

### 옵션
Expand Down Expand Up @@ -262,6 +295,15 @@ const [amount, setAmount] = useState('')
| `applyToInput(el, opts?)` | 커서 보존 1회 재포맷 (빌딩 블록) |
| `createRefBinder(opts?)` | 어떤 프레임워크에서든 쓰는 ref 콜백 팩토리 |

### 프레임워크 진입점

| | |
|---|---|
| `@devslab/numkey/vue` | `NumkeyInput` (v-model → 정식 값), `vNumkey` 디렉티브 |
| `@devslab/numkey/react` | `NumkeyInput` (`value`/`onValueChange` → 정식 값), `useNumkey` |
| `@devslab/numkey/svelte` | `use:numkey` 액션 (`bind:value` → 표시 값, `onValue` → 정식 값) |
| `@devslab/numkey/solid` | `use:numkey` 디렉티브, `useNumkey` ref 팩토리 |

## 참고

- 유럽식 포맷도 옵션으로 지원: `{ separator: '.', decimalPoint: ',' }`이면
Expand All @@ -272,7 +314,15 @@ const [amount, setAmount] = useState('')
양수 전용 필드는 이 표기를 무시합니다.
- 구분자 옆 백스페이스/Delete는 한 번에 인접한 숫자를 지웁니다 (구분자는
건너뛰고, 재포맷이 나머지를 처리).
- 로드맵: 인도식 lakh 그룹핑 (`12,34,567` — 비균일 그룹 크기).

## 로드맵

- ~~`v0.2` — 한글 금액 병기 (`150만`) + hidden 정식 값 동기화~~ ✅ 출시됨
- ~~`v0.3` — 한글 축약 입력 (`3만5천` → 35,000)~~ ✅ 출시됨
- ~~`v0.4` — 구분자 넘는 스마트 삭제 + `min`/`max`~~ ✅ 출시됨
- ~~`v0.5` — Svelte 액션 / Solid 디렉티브~~ ✅ 출시됨
- 인도식 lakh 그룹핑 (`12,34,567` — 비균일 그룹 크기) — 수요가 있으면 진행,
필요하시면 [이슈](https://github.com/devslab-kr/numkey/issues)로 알려주세요

## 기여하기

Expand Down
52 changes: 51 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,39 @@ const [amount, setAmount] = useState('')
<input ref={useNumkey({ decimals: 2 })} defaultValue="1234567" />
```

## Svelte

An action — and it imports nothing from `svelte`, so there is no peer
dependency at all. `bind:value` works: the action re-syncs the binding after
formatting, so the bound variable holds the **display** value. For the
**canonical** value use `onValue`.

```svelte
<script>
import { numkey } from '@devslab/numkey/svelte'
let amount = '' // canonical: "1234567"
let display = '' // display: "1,234,567"
</script>

<input use:numkey={{ decimals: 2, onValue: (v) => (amount = v) }} />
<input use:numkey={2} bind:value={display} />
<input use:numkey /> <!-- options from data-numkey* -->
```

## Solid

A `use:` directive (reactive to a signal) and a ref factory. Solid delegates
`input` at the document level, so `onInput` already reads the formatted
value — no re-sync needed.

```tsx
import { numkey, useNumkey } from '@devslab/numkey/solid'

const [amount, setAmount] = createSignal('') // canonical
<input use:numkey={{ decimals: 2, onValue: setAmount }} />
<input ref={useNumkey({ decimals: 2 })} value="1234567" />
```

## API

### Options
Expand Down Expand Up @@ -265,6 +298,15 @@ const [amount, setAmount] = useState('')
| `applyToInput(el, opts?)` | one caret-preserving reformat (building block) |
| `createRefBinder(opts?)` | ref-callback factory for any framework |

### Framework entry points

| | |
|---|---|
| `@devslab/numkey/vue` | `NumkeyInput` (v-model → canonical), `vNumkey` directive |
| `@devslab/numkey/react` | `NumkeyInput` (`value`/`onValueChange` → canonical), `useNumkey` |
| `@devslab/numkey/svelte` | `use:numkey` action (`bind:value` → display, `onValue` → canonical) |
| `@devslab/numkey/solid` | `use:numkey` directive, `useNumkey` ref factory |

## Notes

- European formats work via options: `{ separator: '.', decimalPoint: ',' }`
Expand All @@ -276,7 +318,15 @@ const [amount, setAmount] = useState('')
form entirely.
- 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).

## Roadmap

- ~~`v0.2` — Korean amount reading (`150만`) + hidden canonical sync~~ ✅ shipped
- ~~`v0.3` — Korean shorthand entry (`3만5천` → 35,000)~~ ✅ shipped
- ~~`v0.4` — smart deletion across separators + `min`/`max`~~ ✅ shipped
- ~~`v0.5` — Svelte action / Solid directive~~ ✅ shipped
- Indian lakh grouping (`12,34,567` — non-uniform group sizes) — demand-gated,
[open an issue](https://github.com/devslab-kr/numkey/issues) if you need it

## Contributing

Expand Down
40 changes: 38 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 19 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@devslab/numkey",
"version": "0.4.2",
"version": "0.5.0",
"publishConfig": {
"access": "public"
},
Expand All @@ -26,6 +26,16 @@
"types": "./dist/react.d.ts",
"import": "./dist/react.js",
"require": "./dist/react.cjs"
},
"./svelte": {
"types": "./dist/svelte.d.ts",
"import": "./dist/svelte.js",
"require": "./dist/svelte.cjs"
},
"./solid": {
"types": "./dist/solid.d.ts",
"import": "./dist/solid.js",
"require": "./dist/solid.cjs"
}
},
"files": [
Expand All @@ -41,19 +51,24 @@
},
"peerDependencies": {
"react": ">=18",
"solid-js": "^1.6.0",
"vue": "^3.3.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"solid-js": {
"optional": true
},
"vue": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^19.0.0",
"jsdom": "^26.1.0",
"solid-js": "^1.9.14",
"tsup": "^8.5.0",
"typescript": "^5.7.0",
"vitest": "^4.1.9",
Expand Down Expand Up @@ -85,6 +100,9 @@
"숫자입력",
"vue",
"react",
"svelte",
"solid",
"solidjs",
"vanilla",
"no-build",
"cdn",
Expand Down
30 changes: 28 additions & 2 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,16 +196,38 @@ export function setValue(
el.value = format(finalize(canonical), opts ?? optionsFromElement(el))
}

export interface BindOptions {
/**
* Re-dispatch a bubbling `input` event after a reformat changes the value.
* Frameworks that attach their own `input` listener before the binding
* (Svelte's `bind:value`) read the value before formatting runs; the
* re-dispatched event lets them pick up the formatted display. Safe
* against loops — reformatting is idempotent, so the second pass changes
* nothing and does not re-dispatch.
*/
resync?: boolean
}

/**
* Bind formatting to a single element. Options are read from the
* `data-numkey*` attributes at event time unless `opts` is given explicitly.
* Returns an unbind function. Binding an already-bound element is a no-op
* that returns the existing unbinder.
*/
export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void {
export function bind(
el: HTMLInputElement,
opts?: NumkeyOptions,
bindOpts?: BindOptions
): () => void {
const existing = unbinders.get(el)
if (existing) return existing

const resync = (changed: boolean): void => {
if (changed && bindOpts?.resync) {
el.dispatchEvent(new Event('input', { bubbles: true }))
}
}

const resolve = (): NumkeyOptions => opts ?? optionsFromElement(el)

// One-time setup: mobile keypad, alignment, server-rendered value.
Expand Down Expand Up @@ -255,12 +277,14 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void {
}

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

settle(initial) // server-rendered value → formatted display
Expand All @@ -269,8 +293,10 @@ export function bind(el: HTMLInputElement, opts?: NumkeyOptions): () => void {

const run = (): void => {
const o = resolve()
if (!isKoreanDraftValue(el.value, o)) applyToInput(el, o)
let changed = false
if (!isKoreanDraftValue(el.value, o)) changed = applyToInput(el, o)
syncExtras(o)
resync(changed)
}

const onCompositionStart = (): void => {
Expand Down
Loading