diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..7278246 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,50 @@ +name: Deploy site + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - run: npm ci + - run: npm run build + + - name: Assemble site + run: | + mkdir -p _site + cp -r site/. _site/ + cp dist/numkey.global.js _site/ + cp dist/numkey.global.js.map _site/ + + - uses: actions/configure-pages@v5 + with: + enablement: true + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 7e3789d..81187c4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ dist/ coverage/ *.log .DS_Store +# local demo copies — the Pages workflow assembles fresh ones from dist/ +site/numkey.global.js +site/numkey.global.js.map diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ec2d3..595d149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Initial release. / 최초 릴리스. +- Opt-in locale formatting: `locale: 'auto' | 'de-DE' | …` / + `data-numkey-locale` derives separators via `Intl.NumberFormat`; without it + the display stays deterministic regardless of the visitor's browser. / + 옵트인 로케일 포맷팅 — 지정하지 않으면 브라우저와 무관하게 표시 고정. +- `setValue(el, canonical)` — programmatic canonical writes (the counterpart + of `getValue`). / 정식 값 프로그래매틱 기록. +- GitHub Pages demo site (`site/`, deployed on main push). / 데모 사이트. - Core: `parse` / `format` / `finalize` — string-first canonical value model (money-safe, no IEEE 754), thousands grouping, leading-zero cleanup, full-width digit normalization, custom group size / separator / diff --git a/README.ko.md b/README.ko.md index 0331b38..120250d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,8 +1,10 @@ # numkey -[![npm](https://img.shields.io/npm/v/@devslab/numkey)](https://www.npmjs.com/package/@devslab/numkey) +[English](README.md) | **한국어** · [라이브 데모](https://devslab-kr.github.io/numkey/) + +[![npm](https://img.shields.io/npm/v/%40devslab%2Fnumkey)](https://www.npmjs.com/package/@devslab/numkey) [![CI](https://github.com/devslab-kr/numkey/actions/workflows/ci.yml/badge.svg)](https://github.com/devslab-kr/numkey/actions/workflows/ci.yml) -[![license](https://img.shields.io/npm/l/@devslab/numkey)](./LICENSE) +[![license](https://img.shields.io/npm/l/%40devslab%2Fnumkey)](./LICENSE) **"문자열인데 숫자야."** 업무 시스템엔 이런 필드가 꼭 있습니다 — 금액, 수량, 단가. 그리고 매번 같은 걸 손으로 다시 만들죠: 실시간 천 단위 콤마(`10,000`), @@ -58,6 +60,7 @@ numkey는 그 인풋을 한 번에 끝냅니다: | `data-numkey-group="4"` | 그룹 크기 (기본 3, 만 단위는 4) | | `data-numkey-separator=" "` | 그룹 구분자 (기본 `,`) | | `data-numkey-point=","` | 필드에 표시되는 소수점 (기본 `.`) | +| `data-numkey-locale="auto"` | 로케일에서 구분자 유도 — `"auto"`(브라우저 언어) 또는 `"de-DE"` 같은 BCP 47 태그 | > `type="text"` 인풋을 쓰세요. numkey가 `inputmode`를 설정해 모바일에서 숫자 > 키패드가 뜹니다. `type="number"`는 커서 API가 없어 포맷팅과 충돌합니다. @@ -121,6 +124,7 @@ const [amount, setAmount] = useState('') | `group` | `3` | 그룹당 자릿수 (만 단위 그룹핑은 4) | | `separator` | `","` | 표시용 그룹 구분자 | | `decimalPoint` | `"."` | 표시용 소수점 (정식 값은 항상 `.`) | +| `locale` | — | **옵트인**: `Intl`로 `separator`/`decimalPoint` 유도 — `"auto"`(브라우저 언어) 또는 BCP 47 태그. 지정하지 않으면 방문자 브라우저와 무관하게 표시가 고정됩니다 (업무 폼의 기본 요구). 명시한 `separator`/`decimalPoint`가 우선. | ### Core (순수 함수) @@ -137,6 +141,7 @@ const [amount, setAmount] = useState('') | `bind(el, opts?)` | 실시간 포맷팅 연결; 해제 함수 반환 | | `observe(root?)` | 현재/미래의 모든 `[data-numkey]` 바인딩 | | `getValue(el, opts?)` | 바인딩된 인풋의 정식 값 | +| `setValue(el, canonical, opts?)` | 정식 값을 포맷된 표시 값으로 기록 | | `applyToInput(el, opts?)` | 커서 보존 1회 재포맷 (빌딩 블록) | | `createRefBinder(opts?)` | 어떤 프레임워크에서든 쓰는 ref 콜백 팩토리 | diff --git a/README.md b/README.md index 9d05111..1168e7d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # numkey -[![npm](https://img.shields.io/npm/v/@devslab/numkey)](https://www.npmjs.com/package/@devslab/numkey) +**English** | [한국어](README.ko.md) · [Live demo](https://devslab-kr.github.io/numkey/) + +[![npm](https://img.shields.io/npm/v/%40devslab%2Fnumkey)](https://www.npmjs.com/package/@devslab/numkey) [![CI](https://github.com/devslab-kr/numkey/actions/workflows/ci.yml/badge.svg)](https://github.com/devslab-kr/numkey/actions/workflows/ci.yml) -[![license](https://img.shields.io/npm/l/@devslab/numkey)](./LICENSE) +[![license](https://img.shields.io/npm/l/%40devslab%2Fnumkey)](./LICENSE) **"It's a string, but it's a number."** Every business app has these fields — amounts, quantities, prices — and every team rebuilds the same input by hand: @@ -60,6 +62,7 @@ value.) | `data-numkey-group="4"` | group size (default 3) | | `data-numkey-separator=" "` | group separator (default `,`) | | `data-numkey-point=","` | decimal mark shown in the field (default `.`) | +| `data-numkey-locale="auto"` | derive separators from a locale — `"auto"` (browser language) or a BCP 47 tag like `"de-DE"` | > Use `type="text"` inputs. numkey sets `inputmode` so mobile keyboards show > the numeric keypad; `type="number"` has no caret API and fights formatting. @@ -123,6 +126,7 @@ const [amount, setAmount] = useState('') | `group` | `3` | digits per group (4 for 만-style grouping) | | `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. | ### Core (pure functions) @@ -139,6 +143,7 @@ const [amount, setAmount] = useState('') | `bind(el, opts?)` | attach live formatting; returns an unbind function | | `observe(root?)` | bind all `[data-numkey]` now and as they appear | | `getValue(el, opts?)` | canonical value of a bound input | +| `setValue(el, canonical, opts?)` | write a canonical value as the formatted display | | `applyToInput(el, opts?)` | one caret-preserving reformat (building block) | | `createRefBinder(opts?)` | ref-callback factory for any framework | diff --git a/package.json b/package.json index 10e65fa..4b48287 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "type": "git", "url": "git+https://github.com/devslab-kr/numkey.git" }, - "homepage": "https://github.com/devslab-kr/numkey#readme", + "homepage": "https://devslab-kr.github.io/numkey/", "bugs": { "url": "https://github.com/devslab-kr/numkey/issues" }, diff --git a/site/index.html b/site/index.html index 2fc333e..2568189 100644 --- a/site/index.html +++ b/site/index.html @@ -3,37 +3,153 @@ -numkey — numeric input formatting demo +numkey — numeric input formatting. One script tag. + -

numkey

-

One script tag. Markup attributes. No build step.

- - +

numkey

+

It's a string, but it's a number. — Numeric input formatting, done once.
+문자열인데 숫자인 필드 — 콤마·커서·정렬·앞자리 0, 한 번에 끝.

- - + - - +
+

Try it — everything below is just markup / 전부 마크업만으로 동작합니다

-

Canonical value of the first field: ?

+ + +
canonical:
- + + + + + + + + +
+ +
+

Locale (opt-in) / 로케일은 옵트인 — 기본값은 브라우저와 무관하게 고정

+

+ By default the display is deterministic (1,234,567.89) no matter the visitor's + browser. Add data-numkey-locale only when you want locale separators. +

+ + +
canonical stays:
+
+ +
+

No build step / JSP·PHP·정적 페이지 — 스크립트 태그 한 줄

+
<script src="https://cdn.jsdelivr.net/npm/@devslab/numkey"></script>
+
+<input data-numkey>                       <!-- 1,234,567 -->
+<input data-numkey="2">                   <!-- 1,234.56 -->
+<input data-numkey data-numkey-negative>  <!-- minus ok -->
+

Vue 3 / React adapters and the full API: + README. + Wrong-keyboard-layout text in the same form? See numkey's sibling + kokey.

+
+ + + + diff --git a/src/core.test.ts b/src/core.test.ts index f335a73..ed8f754 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -4,6 +4,7 @@ import { countSignificant, finalize, format, + localeSeparators, parse } from './core' @@ -94,6 +95,42 @@ describe('format — canonical → display', () => { }) }) +describe('locale — opt-in separator derivation via Intl', () => { + it('derives separators from a BCP 47 tag', () => { + expect(localeSeparators('de-DE')).toEqual({ + separator: '.', + decimalPoint: ',' + }) + expect(localeSeparators('ko-KR')).toEqual({ + separator: ',', + decimalPoint: '.' + }) + }) + + it('format/parse round-trip under a locale', () => { + const o = { decimals: 2, locale: 'de-DE' } + expect(format('1234567.89', o)).toBe('1.234.567,89') + expect(parse('1.234.567,89', o)).toBe('1234567.89') + }) + + it('explicit separator/decimalPoint win over the locale', () => { + expect(format('1234567', { locale: 'de-DE', separator: ' ' })).toBe( + '1 234 567' + ) + }) + + it('falls back to deterministic defaults on an invalid tag', () => { + expect(localeSeparators('no-such-locale-tag-!!!')).toEqual({ + separator: ',', + decimalPoint: '.' + }) + }) + + it('without locale the display never depends on the environment', () => { + expect(format('1234567.5', { decimals: 1 })).toBe('1,234,567.5') + }) +}) + describe('finalize — settle transient states', () => { it('drops a trailing decimal mark and a lone minus', () => { expect(finalize('1234.')).toBe('1234') diff --git a/src/core.ts b/src/core.ts index 50b040f..285ea95 100644 --- a/src/core.ts +++ b/src/core.ts @@ -21,17 +21,68 @@ export interface NumkeyOptions { separator?: string /** Decimal mark in the display value. The canonical value always uses '.'. Default '.'. */ decimalPoint?: string + /** + * Derive `separator`/`decimalPoint` from a locale via `Intl.NumberFormat`: + * a BCP 47 tag ("de-DE") or "auto" (the browser language). OPT-IN — by + * default the display is deterministic (`,` / `.`) regardless of the + * visitor's browser, which is what business forms usually need. Explicit + * `separator`/`decimalPoint` win over the locale. + */ + locale?: string } type Resolved = Required +const localeCache = new Map() + +/** + * The group separator and decimal mark a locale uses ("de-DE" → `.` / `,`). + * "auto" (or empty) resolves the browser language; unknown tags and non-Intl + * environments fall back to `,` / `.`. + */ +export function localeSeparators(locale?: string): { + separator: string + decimalPoint: string +} { + const tag = + !locale || locale === 'auto' + ? typeof navigator !== 'undefined' + ? navigator.language + : undefined + : locale + const key = tag ?? '' + const cached = localeCache.get(key) + if (cached) return cached + + let out = { separator: ',', decimalPoint: '.' } + try { + const parts = new Intl.NumberFormat(tag).formatToParts(1234567.8) + out = { + separator: parts.find((p) => p.type === 'group')?.value ?? ',', + decimalPoint: parts.find((p) => p.type === 'decimal')?.value ?? '.' + } + } catch { + /* invalid tag → deterministic defaults */ + } + localeCache.set(key, out) + return out +} + export function resolveOptions(opts?: NumkeyOptions): Resolved { + let separator = opts?.separator + let decimalPoint = opts?.decimalPoint + if (opts?.locale && (separator === undefined || decimalPoint === undefined)) { + const derived = localeSeparators(opts.locale) + separator ??= derived.separator + decimalPoint ??= derived.decimalPoint + } return { decimals: opts?.decimals ?? 0, negative: opts?.negative ?? false, group: opts?.group ?? 3, - separator: opts?.separator ?? ',', - decimalPoint: opts?.decimalPoint ?? '.' + separator: separator ?? ',', + decimalPoint: decimalPoint ?? '.', + locale: opts?.locale ?? '' } } diff --git a/src/dom.test.ts b/src/dom.test.ts index ed6ec16..c4447f8 100644 --- a/src/dom.test.ts +++ b/src/dom.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' -import { applyToInput, bind, getValue, observe } from './dom' +import { applyToInput, bind, getValue, observe, setValue } from './dom' function makeInput(attrs: Record = {}): HTMLInputElement { const el = document.createElement('input') @@ -129,12 +129,38 @@ describe('bind — attribute-driven behavior', () => { }) }) -describe('getValue — canonical read-back', () => { - it('returns the unformatted, settled value using the element options', () => { +describe('getValue / setValue — canonical read/write', () => { + it('getValue returns the unformatted, settled value using the element options', () => { const el = makeInput({ 'data-numkey': '2', 'data-numkey-negative': '' }) el.value = '-1,234,567.' expect(getValue(el)).toBe('-1234567') }) + + it('setValue writes a canonical value as the formatted display', () => { + const el = makeInput({ 'data-numkey': '2' }) + setValue(el, '1234567.89') + expect(el.value).toBe('1,234,567.89') + expect(getValue(el)).toBe('1234567.89') + }) + + it('canonical values survive a locale switch via setValue', () => { + const el = makeInput({ 'data-numkey': '2' }) + setValue(el, '1234567.89') + const canonical = getValue(el) + el.setAttribute('data-numkey-locale', 'de-DE') + setValue(el, canonical) + expect(el.value).toBe('1.234.567,89') + expect(getValue(el)).toBe('1234567.89') + }) +}) + +describe('data-numkey-locale — opt-in locale formatting', () => { + it('formats with the locale separators from the attribute', () => { + const el = makeInput({ 'data-numkey': '2', 'data-numkey-locale': 'de-DE' }) + bind(el) + feed(el, '1234567,8') + expect(el.value).toBe('1.234.567,8') + }) }) describe('observe — auto-init', () => { diff --git a/src/dom.ts b/src/dom.ts index 66a6a26..0d1195d 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -36,6 +36,7 @@ export function optionsFromElement(el: HTMLInputElement): NumkeyOptions { const group = el.getAttribute('data-numkey-group') const separator = el.getAttribute('data-numkey-separator') const decimalPoint = el.getAttribute('data-numkey-point') + const locale = el.getAttribute('data-numkey-locale') const opts: NumkeyOptions = { decimals: main ? parseInt(main, 10) || 0 : 0, negative: el.hasAttribute('data-numkey-negative') @@ -43,6 +44,7 @@ export function optionsFromElement(el: HTMLInputElement): NumkeyOptions { if (group) opts.group = parseInt(group, 10) || 3 if (separator !== null) opts.separator = separator if (decimalPoint !== null) opts.decimalPoint = decimalPoint + if (locale) opts.locale = locale return opts } @@ -99,6 +101,21 @@ export function getValue(el: HTMLInputElement, opts?: NumkeyOptions): string { return finalize(parse(el.value, opts ?? optionsFromElement(el))) } +/** + * Set an input from a CANONICAL value ("1234567.89") — the write counterpart + * of `getValue`. Use this for programmatic updates (loading a record, + * switching display options): assigning a canonical string to `el.value` + * directly would show it unformatted, and re-parsing it under a non-`.` + * decimal mark would corrupt it. + */ +export function setValue( + el: HTMLInputElement, + canonical: string, + opts?: NumkeyOptions +): void { + el.value = format(finalize(canonical), opts ?? optionsFromElement(el)) +} + /** * Bind formatting to a single element. Options are read from the * `data-numkey*` attributes at event time unless `opts` is given explicitly. diff --git a/src/index.ts b/src/index.ts index 010fc91..22557d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { countSignificant, finalize, format, + localeSeparators, parse, resolveOptions, type NumkeyOptions @@ -14,5 +15,6 @@ export { finalizeInput, getValue, observe, - optionsFromElement + optionsFromElement, + setValue } from './dom'