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'
+ }
+ }
+})