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
50 changes: 50 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down
9 changes: 7 additions & 2 deletions README.ko.md
Original file line number Diff line number Diff line change
@@ -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`),
Expand Down Expand Up @@ -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가 없어 포맷팅과 충돌합니다.
Expand Down Expand Up @@ -121,6 +124,7 @@ const [amount, setAmount] = useState('')
| `group` | `3` | 그룹당 자릿수 (만 단위 그룹핑은 4) |
| `separator` | `","` | 표시용 그룹 구분자 |
| `decimalPoint` | `"."` | 표시용 소수점 (정식 값은 항상 `.`) |
| `locale` | — | **옵트인**: `Intl`로 `separator`/`decimalPoint` 유도 — `"auto"`(브라우저 언어) 또는 BCP 47 태그. 지정하지 않으면 방문자 브라우저와 무관하게 표시가 고정됩니다 (업무 폼의 기본 요구). 명시한 `separator`/`decimalPoint`가 우선. |

### Core (순수 함수)

Expand All @@ -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 콜백 팩토리 |

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
158 changes: 137 additions & 21 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,153 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>numkey — numeric input formatting demo</title>
<title>numkey — numeric input formatting. One script tag.</title>
<meta name="description" content="Live thousands grouping, caret-safe editing, leading-zero cleanup, right alignment. Zero dependencies. Plain <script>, Vue, React.">
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 3rem auto; padding: 0 1rem; }
label { display: block; margin: 1.25rem 0 0.25rem; font-weight: 600; }
input { font-size: 1.1rem; padding: 0.4rem 0.6rem; width: 16rem; }
code { background: #f4f4f4; padding: 0.1rem 0.3rem; border-radius: 3px; }
.canonical { color: #666; font-size: 0.9rem; margin-top: 0.25rem; }
:root {
--fg: #1a1a1a; --muted: #6b7280; --line: #e5e7eb;
--accent: #0f766e; --code-bg: #f6f8fa; --card: #ffffff; --bg: #fafafa;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e5e7eb; --muted: #9ca3af; --line: #374151;
--accent: #2dd4bf; --code-bg: #111827; --card: #1f2937; --bg: #111318;
}
}
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--fg); background: var(--bg);
max-width: 720px; margin: 0 auto; padding: 3rem 1.25rem 4rem;
line-height: 1.6;
}
h1 { font-size: 2.4rem; margin: 0; letter-spacing: -0.02em; }
h1 .key { color: var(--accent); }
.tagline { color: var(--muted); margin: 0.35rem 0 0; font-size: 1.05rem; }
.links { margin: 1rem 0 2.5rem; display: flex; gap: 1rem; flex-wrap: wrap; font-size: 0.95rem; }
.links a { color: var(--accent); text-decoration: none; }
.links a:hover { text-decoration: underline; }
.card {
background: var(--card); border: 1px solid var(--line); border-radius: 10px;
padding: 1.25rem 1.5rem; margin: 1.25rem 0;
}
.card h2 { font-size: 1.05rem; margin: 0 0 0.75rem; }
label { display: block; margin: 1rem 0 0.3rem; font-size: 0.9rem; color: var(--muted); }
label code, p code, li code {
background: var(--code-bg); padding: 0.1rem 0.35rem; border-radius: 4px;
font-size: 0.85em; border: 1px solid var(--line);
}
input[type="text"] {
font-size: 1.15rem; padding: 0.5rem 0.7rem; width: 100%; max-width: 20rem;
border: 1px solid var(--line); border-radius: 8px;
background: var(--bg); color: var(--fg);
}
input:focus { outline: 2px solid var(--accent); border-color: transparent; }
select {
font-size: 0.95rem; padding: 0.35rem 0.5rem; border-radius: 6px;
border: 1px solid var(--line); background: var(--bg); color: var(--fg);
}
.canonical { font-size: 0.85rem; color: var(--muted); margin-top: 0.35rem; }
.canonical code { color: var(--accent); }
pre {
background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px;
padding: 0.9rem 1rem; overflow-x: auto; font-size: 0.85rem; line-height: 1.5;
}
.ko { color: var(--muted); font-size: 0.92rem; }
footer { margin-top: 3rem; color: var(--muted); font-size: 0.85rem; border-top: 1px solid var(--line); padding-top: 1rem; }
footer a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<h1>numkey</h1>
<p>One script tag. Markup attributes. No build step.</p>

<label>Integer <code>data-numkey</code></label>
<input id="int" data-numkey value="1234567">
<h1>num<span class="key">key</span></h1>
<p class="tagline">It's a string, but it's a number. — Numeric input formatting, done once.<br>
<span class="ko">문자열인데 숫자인 필드 — 콤마·커서·정렬·앞자리 0, 한 번에 끝.</span></p>

<label>2 decimals <code>data-numkey="2"</code></label>
<input id="dec" data-numkey="2">
<div class="links">
<a href="https://github.com/devslab-kr/numkey">GitHub</a>
<a href="https://www.npmjs.com/package/@devslab/numkey">npm</a>
<a href="https://github.com/devslab-kr/numkey#readme">English docs</a>
<a href="https://github.com/devslab-kr/numkey/blob/main/README.ko.md">한국어 문서</a>
</div>

<label>Negative <code>data-numkey data-numkey-negative</code></label>
<input id="neg" data-numkey data-numkey-negative>
<div class="card">
<h2>Try it — everything below is just markup <span class="ko">/ 전부 마크업만으로 동작합니다</span></h2>

<p class="canonical">Canonical value of the first field: <code id="out">?</code></p>
<label>Integer · <code>&lt;input data-numkey&gt;</code></label>
<input type="text" id="int" data-numkey value="1234567">
<div class="canonical">canonical: <code id="int-out"></code></div>

<script src="../dist/numkey.global.js"></script>
<label>2 decimals · <code>&lt;input data-numkey="2"&gt;</code></label>
<input type="text" id="dec" data-numkey="2" placeholder="1234567.89">

<label>Negative · <code>data-numkey-negative</code></label>
<input type="text" id="neg" data-numkey data-numkey-negative placeholder="-9999">

<label>Paste sanitizing — try pasting <code>₩ 1,234,567원</code> or full-width <code>1234</code></label>
<input type="text" data-numkey placeholder="paste here">
</div>

<div class="card">
<h2>Locale (opt-in) <span class="ko">/ 로케일은 옵트인 — 기본값은 브라우저와 무관하게 고정</span></h2>
<p style="font-size:0.9rem; margin-top:0">
By default the display is deterministic (<code>1,234,567.89</code>) no matter the visitor's
browser. Add <code>data-numkey-locale</code> only when you want locale separators.
</p>
<label>
<select id="locale-select">
<option value="">default (deterministic)</option>
<option value="auto">auto — browser language</option>
<option value="de-DE">de-DE — 1.234.567,89</option>
<option value="fr-FR">fr-FR — 1 234 567,89</option>
<option value="en-US">en-US — 1,234,567.89</option>
</select>
</label>
<input type="text" id="loc" data-numkey="2" value="1234567.89">
<div class="canonical">canonical stays: <code id="loc-out"></code></div>
</div>

<div class="card">
<h2>No build step <span class="ko">/ JSP·PHP·정적 페이지 — 스크립트 태그 한 줄</span></h2>
<pre>&lt;script src="https://cdn.jsdelivr.net/npm/@devslab/numkey"&gt;&lt;/script&gt;

&lt;input data-numkey&gt; &lt;!-- 1,234,567 --&gt;
&lt;input data-numkey="2"&gt; &lt;!-- 1,234.56 --&gt;
&lt;input data-numkey data-numkey-negative&gt; &lt;!-- minus ok --&gt;</pre>
<p style="font-size:0.9rem">Vue 3 / React adapters and the full API:
<a href="https://github.com/devslab-kr/numkey#readme" style="color:var(--accent)">README</a>.
Wrong-keyboard-layout text in the same form? See numkey's sibling
<a href="https://devslab-kr.github.io/kokey/" style="color:var(--accent)">kokey</a>.</p>
</div>

<footer>
MIT · <a href="https://github.com/devslab-kr">devslab</a> ·
the “-key” input family: <a href="https://devslab-kr.github.io/kokey/">kokey</a> (keyboard layouts) · numkey (numbers)
</footer>

<script src="numkey.global.js"></script>
<script>
const int = document.getElementById('int')
const out = document.getElementById('out')
const sync = () => { out.textContent = JSON.stringify(numkey.getValue(int)) }
int.addEventListener('input', sync)
sync()
// canonical read-back demos
var intEl = document.getElementById('int')
var intOut = document.getElementById('int-out')
var syncInt = function () { intOut.textContent = JSON.stringify(numkey.getValue(intEl)) }
intEl.addEventListener('input', syncInt)
syncInt()

// locale switcher: canonical value survives the display change
var locEl = document.getElementById('loc')
var locOut = document.getElementById('loc-out')
var syncLoc = function () { locOut.textContent = JSON.stringify(numkey.getValue(locEl)) }
locEl.addEventListener('input', syncLoc)
syncLoc()

document.getElementById('locale-select').addEventListener('change', function (e) {
var canonical = numkey.getValue(locEl)
if (e.target.value) locEl.setAttribute('data-numkey-locale', e.target.value)
else locEl.removeAttribute('data-numkey-locale')
numkey.setValue(locEl, canonical)
syncLoc()
})
</script>
</body>
</html>
37 changes: 37 additions & 0 deletions src/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
countSignificant,
finalize,
format,
localeSeparators,
parse
} from './core'

Expand Down Expand Up @@ -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')
Expand Down
Loading