diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ddbbc..9dc8cba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.ko.md b/README.ko.md index 1779dcc..66d8743 100644 --- a/README.ko.md +++ b/README.ko.md @@ -227,6 +227,39 @@ const [amount, setAmount] = useState('') ``` +## Svelte + +액션입니다 — `svelte`에서 아무것도 import하지 않아 peer dependency 자체가 +없습니다. `bind:value`도 동작합니다(포맷 후 바인딩을 재동기화). 다만 +바인딩된 변수에는 **표시 값**이 들어가므로, **정식 값**이 필요하면 +`onValue`를 쓰세요. + +```svelte + + + (amount = v) }} /> + + +``` + +## Solid + +`use:` 디렉티브(시그널에 반응)와 ref 팩토리입니다. Solid는 `input`을 +document 레벨에 위임하므로 `onInput`이 이미 포맷된 값을 읽습니다 — +재동기화가 필요 없습니다. + +```tsx +import { numkey, useNumkey } from '@devslab/numkey/solid' + +const [amount, setAmount] = createSignal('') // 정식 값 + + +``` + ## API ### 옵션 @@ -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: ',' }`이면 @@ -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)로 알려주세요 ## 기여하기 diff --git a/README.md b/README.md index fe1a85a..43164fe 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,39 @@ const [amount, setAmount] = useState('') ``` +## 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 + + + (amount = v) }} /> + + +``` + +## 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 + + +``` + ## API ### Options @@ -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: ',' }` @@ -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 diff --git a/package-lock.json b/package-lock.json index d26880f..a628e3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@devslab/numkey", - "version": "0.1.0", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@devslab/numkey", - "version": "0.1.0", + "version": "0.4.2", "license": "MIT", "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", @@ -2764,6 +2765,29 @@ "node": ">=v12.22.7" } }, + "node_modules/seroval": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.6.tgz", + "integrity": "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.6.tgz", + "integrity": "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2771,6 +2795,18 @@ "dev": true, "license": "ISC" }, + "node_modules/solid-js": { + "version": "1.9.14", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.14.tgz", + "integrity": "sha512-sAEXC0Kk0S1EDg+8ysEWJDbYhA3RRoEjwuySUGlKIemeo0I5YZfOyumNjNs9Sv3y2nmhD+0rW66ag2HsMuQiGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.5.4", + "seroval-plugins": "~1.5.4" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/package.json b/package.json index ae8e8be..60d0e95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@devslab/numkey", - "version": "0.4.2", + "version": "0.5.0", "publishConfig": { "access": "public" }, @@ -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": [ @@ -41,12 +51,16 @@ }, "peerDependencies": { "react": ">=18", + "solid-js": "^1.6.0", "vue": "^3.3.0" }, "peerDependenciesMeta": { "react": { "optional": true }, + "solid-js": { + "optional": true + }, "vue": { "optional": true } @@ -54,6 +68,7 @@ "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", @@ -85,6 +100,9 @@ "숫자입력", "vue", "react", + "svelte", + "solid", + "solidjs", "vanilla", "no-build", "cdn", diff --git a/src/dom.ts b/src/dom.ts index 2dc9c73..49a5d2e 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -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. @@ -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 @@ -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 => { diff --git a/src/solid.ts b/src/solid.ts new file mode 100644 index 0000000..a489c0a --- /dev/null +++ b/src/solid.ts @@ -0,0 +1,75 @@ +/** + * Solid adapters. + * + * `useNumkey` — ref factory (Solid refs are called once with the element, + * and listeners die with it, so no explicit cleanup is needed): + * + * import { useNumkey } from '@devslab/numkey/solid' + * + * + * `numkey` — `use:` directive, reactive to a signal: + * + * + * + * + * Solid needs no resync: `input` is delegated at the document level, so the + * element-level formatting listener runs first and `onInput` already reads + * the formatted value. + */ +import { createRenderEffect, onCleanup } from 'solid-js' +import type { NumkeyOptions } from './core' +import { bind, createRefBinder, getValue } from './dom' + +export type NumkeyDirectiveParam = NumkeyDirectiveOptions | number | undefined + +export type NumkeyDirectiveOptions = NumkeyOptions & { + /** Called with the CANONICAL value whenever it changes ("1234567"). */ + onValue?: (canonical: string) => void +} + +function toOptions( + param: NumkeyDirectiveParam +): NumkeyDirectiveOptions | undefined { + return typeof param === 'number' ? { decimals: param } : param +} + +export function useNumkey( + opts?: NumkeyOptions +): (el: HTMLInputElement) => void { + return createRefBinder(opts) +} + +export function numkey( + el: HTMLInputElement, + param: () => NumkeyDirectiveParam +): void { + createRenderEffect(() => { + const { onValue, ...core } = toOptions(param()) ?? {} + const opts = Object.keys(core).length > 0 ? core : undefined + onCleanup(bind(el, opts)) + + if (!onValue) return + let last: string | null = null + const report = (): void => { + const canonical = getValue(el, opts) + if (canonical === last) return + last = canonical + onValue(canonical) + } + el.addEventListener('input', report) + el.addEventListener('blur', report) + onCleanup(() => { + el.removeEventListener('input', report) + el.removeEventListener('blur', report) + }) + report() // server-rendered / initial value + }) +} + +declare module 'solid-js' { + namespace JSX { + interface Directives { + numkey: NumkeyDirectiveParam + } + } +} diff --git a/src/svelte-solid.test.ts b/src/svelte-solid.test.ts new file mode 100644 index 0000000..2cfbe12 --- /dev/null +++ b/src/svelte-solid.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { createRoot, createSignal } from 'solid-js' +import { numkey as svelteNumkey } from './svelte' +import { numkey as solidNumkey, useNumkey } from './solid' + +function makeInput(value = ''): HTMLInputElement { + const el = document.createElement('input') + el.value = value + document.body.appendChild(el) + return el +} + +function type(el: HTMLInputElement, value: string): void { + el.value = value + el.setSelectionRange(value.length, value.length) + el.dispatchEvent(new Event('input', { bubbles: true })) +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('numkey (Svelte action)', () => { + it('formats as you type, updates options, destroys', () => { + const el = makeInput() + const action = svelteNumkey(el, 2) + + type(el, '1234567.89') + expect(el.value).toBe('1,234,567.89') + + // integers now: the point is not a decimal mark, so its digits survive + action.update(0) + type(el, '1234567.89') + expect(el.value).toBe('123,456,789') + + action.destroy() + type(el, '7654321') + expect(el.value).toBe('7654321') + }) + + it('accepts an options object and the decimals shorthand alike', () => { + const el = makeInput() + const action = svelteNumkey(el, { negative: true }) + type(el, '-1234567') + expect(el.value).toBe('-1,234,567') + action.destroy() + }) + + it('without a parameter falls back to the data-numkey attributes', () => { + const el = makeInput() + el.setAttribute('data-numkey', '2') + const action = svelteNumkey(el) + type(el, '1234.5') + expect(el.value).toBe('1,234.5') + action.destroy() + }) + + it('resyncs so an earlier-registered listener (bind:value) sees the formatted value', () => { + const el = makeInput() + // Svelte registers bind:value's input listener before the action runs + let bound = '' + el.addEventListener('input', () => { + bound = el.value + }) + const action = svelteNumkey(el, 0) + type(el, '1234567') + expect(el.value).toBe('1,234,567') + expect(bound).toBe('1,234,567') // picked up via the re-dispatched event + action.destroy() + }) + + it('reports the canonical value through onValue, without duplicates', () => { + const el = makeInput() + const seen: string[] = [] + const action = svelteNumkey(el, { + decimals: 2, + onValue: (v) => seen.push(v) + }) + + type(el, '1234567.89') + el.dispatchEvent(new Event('blur', { bubbles: true })) + expect(seen[seen.length - 1]).toBe('1234567.89') // canonical, not "1,234,567.89" + expect(seen).toEqual([...new Set(seen)]) // resync does not double-report + action.destroy() + }) + + it('formats a server-rendered value on mount and reports it', () => { + const el = makeInput('1234567') + const seen: string[] = [] + const action = svelteNumkey(el, { onValue: (v) => seen.push(v) }) + expect(el.value).toBe('1,234,567') + expect(seen).toEqual(['1234567']) + action.destroy() + }) + + it('stops reporting after destroy', () => { + const el = makeInput() + const seen: string[] = [] + const action = svelteNumkey(el, { onValue: (v) => seen.push(v) }) + action.destroy() + type(el, '1234567') + expect(seen).toEqual(['']) + }) +}) + +describe('numkey (Solid directive)', () => { + it('binds reactively to a signal and cleans up on dispose', () => { + const el = makeInput() + // set up inside the root, drive from outside it — updates inside the + // createRoot callback are batched and would not re-run the effect yet + let setDecimals!: (n: number) => number + let dispose!: () => void + createRoot((d) => { + dispose = d + const [decimals, set] = createSignal(2) + setDecimals = set + solidNumkey(el, decimals) + }) + + type(el, '1234567.89') + expect(el.value).toBe('1,234,567.89') + + // integers now: the point is not a decimal mark, so its digits survive + setDecimals(0) + type(el, '1234567.89') + expect(el.value).toBe('123,456,789') + + dispose() + type(el, '7654321') + expect(el.value).toBe('7654321') + }) + + it('reports the canonical value through onValue', () => { + const el = makeInput() + const seen: string[] = [] + let dispose!: () => void + createRoot((d) => { + dispose = d + solidNumkey(el, () => ({ decimals: 2, onValue: (v) => seen.push(v) })) + }) + + type(el, '1234567.89') + expect(seen[seen.length - 1]).toBe('1234567.89') + + dispose() + type(el, '1') + expect(seen[seen.length - 1]).toBe('1234567.89') // no reports after dispose + }) + + it('useNumkey returns a ref binder', () => { + const el = makeInput() + useNumkey({ decimals: 2 })(el) + type(el, '1234567.8') + expect(el.value).toBe('1,234,567.8') + }) +}) diff --git a/src/svelte.ts b/src/svelte.ts new file mode 100644 index 0000000..25b3613 --- /dev/null +++ b/src/svelte.ts @@ -0,0 +1,90 @@ +/** + * Svelte adapters — actions for `use:`. Dependency-free: Svelte actions are + * plain functions, so nothing here imports the `svelte` package and no peer + * dependency is needed. + * + * + * + * + * + * (amount = v) }} /> + * + * `bind:value` works: Svelte attaches its own `input` listener before the + * action runs, so the action binds with `resync` — after a reformat changes + * the value it re-dispatches `input`, and the second (idempotent) pass lets + * `bind:value` read the formatted display. + * + * `bind:value` holds the DISPLAY value ("1,234,567"). For the CANONICAL + * value — the money-safe string you submit — use `onValue`, the action's + * counterpart to the React component's `onValueChange`. + */ +import type { NumkeyOptions } from './core' +import { bind, getValue } from './dom' + +/** Action parameter: options, a decimals shorthand, or nothing. */ +export type NumkeyActionParam = NumkeyActionOptions | number | undefined + +export type NumkeyActionOptions = NumkeyOptions & { + /** Called with the CANONICAL value whenever it changes ("1234567"). */ + onValue?: (canonical: string) => void +} + +/** Structural match for svelte's ActionReturn — no svelte import needed. */ +export interface NumkeyActionReturn { + update: (param?: NumkeyActionParam) => void + destroy: () => void +} + +function toOptions(param: NumkeyActionParam): NumkeyActionOptions | undefined { + return typeof param === 'number' ? { decimals: param } : param +} + +export function numkey( + node: HTMLInputElement, + param?: NumkeyActionParam +): NumkeyActionReturn { + let opts = toOptions(param) + let unbind: () => void + let stopValue: (() => void) | null = null + + const start = (): void => { + // `onValue` is ours, not a core option — pass the rest through untouched. + const { onValue, ...core } = opts ?? {} + const hasCore = Object.keys(core).length > 0 + unbind = bind(node, hasCore ? core : undefined, { resync: true }) + + if (!onValue) return + // The resynced `input` event re-enters this listener, so report only + // when the canonical value actually changed. + let last: string | null = null + const report = (): void => { + const canonical = getValue(node, hasCore ? core : undefined) + if (canonical === last) return + last = canonical + onValue(canonical) + } + node.addEventListener('input', report) + node.addEventListener('blur', report) + stopValue = () => { + node.removeEventListener('input', report) + node.removeEventListener('blur', report) + } + report() // server-rendered / initial value + } + + const stop = (): void => { + stopValue?.() + stopValue = null + unbind() + } + + start() + return { + update(next?: NumkeyActionParam) { + stop() + opts = toOptions(next) + start() + }, + destroy: stop + } +} diff --git a/tsup.config.ts b/tsup.config.ts index 7bb16c9..ccaaff9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,7 +5,9 @@ export default defineConfig([ entry: { index: 'src/index.ts', vue: 'src/vue.ts', - react: 'src/react.ts' + react: 'src/react.ts', + svelte: 'src/svelte.ts', + solid: 'src/solid.ts' }, format: ['esm', 'cjs'], dts: true, diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..70aff80 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + resolve: { + alias: { + // vitest resolves solid-js to its node/SSR build, which has no + // reactivity — the Solid directive tests need the browser runtime + 'solid-js': 'solid-js/dist/dev.js' + } + } +})