diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9dc8cba..3d5a88d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,35 @@
# Changelog
+## 0.6.0 (2026-08-08)
+
+### Added
+
+- **Indian lakh/crore grouping** — `group` now accepts a
+ `[primary, secondary]` pair, so the last `primary` digits form one group
+ and everything above is cut every `secondary`: `{ group: [3, 2] }` gives
+ `12,34,56,789` (and `1,00,000` for a lakh, `1,00,00,000` for a crore).
+ Available as `data-numkey-group="3,2"` too. A single number still means
+ uniform grouping (`4` → `1234,5678`), and `group: 0` now turns separators
+ off instead of producing a pathological regex.
+ **인도식 lakh/crore 그룹핑** — `group`에 `[primary, secondary]` 쌍 지원
+ (`12,34,56,789`), 속성은 `data-numkey-group="3,2"`.
+
+### Fixed
+
+- **`locale` now derives group sizes, not just separators.** A field with
+ `locale: 'en-IN'` (or `data-numkey-locale="en-IN"`) rendered
+ `123,456,789` — the separator was right but the grouping was not what
+ that locale uses. It now formats `12,34,56,789`, matching
+ `Intl.NumberFormat`. Locales with ordinary thousands grouping are
+ unaffected, and an explicit `group` still wins.
+ **`locale`가 구분자만 따르고 그룹 크기는 무시하던 문제 수정** — `en-IN`
+ 필드가 이제 `12,34,56,789`로 나옵니다. 다른 로케일은 영향 없음.
+
+### Changed
+
+- `localeSeparators(tag)` additionally returns `group: [primary, secondary]`.
+ / `localeSeparators`가 `group`을 함께 반환합니다.
+
## 0.5.0 (2026-08-08)
### Added
diff --git a/README.ko.md b/README.ko.md
index 66d8743..816a29c 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -81,7 +81,7 @@ numkey는 그 인풋을 한 번에 끝냅니다:
| `data-numkey` | **스위치** — 인풋을 바인딩; 값은 최대 소수 자릿수 (빈 값 = 정수) |
| `data-numkey-negative` | 앞자리 마이너스 허용 |
| `data-numkey-align="left"` | 자동 오른쪽 정렬 옵트아웃 |
-| `data-numkey-group="4"` | 그룹 크기 (기본 3, 만 단위는 4) |
+| `data-numkey-group="4"` | 그룹 크기 (기본 3, 만 단위는 4); 인도식 lakh는 `"3,2"` — 아래 참조 |
| `data-numkey-separator=" "` | 그룹 구분자 (기본 `,`) |
| `data-numkey-point=","` | 필드에 표시되는 소수점 (기본 `.`) |
| `data-numkey-locale` | 로케일에서 구분자 유도 — 아래 참조 |
@@ -114,6 +114,26 @@ numkey는 그 인풋을 한 번에 끝냅니다:
돌려줍니다. 일반 폼 POST는 *표시 값*을 전송하므로, 로케일을 쓰는 폼은
`data-numkey-name`(아래)으로 전송하거나 서버에서 정규화하세요.
+### 인도식 lakh/crore 그룹핑
+
+인도식 표기는 마지막 세 자리를 묶고 그 위로는 두 자리씩 끊습니다 —
+`123,456,789`가 아니라 `12,34,56,789`. `group`에 `[primary, secondary]`
+쌍을 주거나, 로케일만 지정하면 됩니다:
+
+```html
+
+
+```
+
+```ts
+format('10000000', { group: [3, 2] }) // '1,00,00,000' (1 crore)
+format('123456789', { locale: 'en-IN' }) // '12,34,56,789'
+```
+
+명시한 `group`이 로케일보다 항상 우선하고, 숫자 하나면 기존처럼 균일
+그룹핑입니다(`4` → `1234,5678`, 만 단위). `group: 0`이면 구분자를 아예
+넣지 않습니다.
+
### 한글 금액 병기
은행·핀테크 UI가 금액 필드 옆에 그리는 "150만" 힌트 — 프로젝트마다
@@ -268,7 +288,7 @@ const [amount, setAmount] = createSignal('') // 정식 값
|---|---|---|
| `decimals` | `0` | 최대 소수 자릿수 (0 = 정수만) |
| `negative` | `false` | 앞자리 마이너스 허용 |
-| `group` | `3` | 그룹당 자릿수 (만 단위 그룹핑은 4) |
+| `group` | `3` | 그룹당 자릿수 — 만 단위는 `4`, 인도식 lakh는 `[3, 2]`, `0`이면 구분 없음 |
| `separator` | `","` | 표시용 그룹 구분자 |
| `decimalPoint` | `"."` | 표시용 소수점 (정식 값은 항상 `.`) |
| `locale` | — | **옵트인**: `Intl`로 `separator`/`decimalPoint` 유도 — `"auto"`(브라우저 언어) 또는 BCP 47 태그. 지정하지 않으면 방문자 브라우저와 무관하게 표시가 고정됩니다 (업무 폼의 기본 요구). 명시한 `separator`/`decimalPoint`가 우선. |
@@ -321,8 +341,10 @@ const [amount, setAmount] = createSignal('') // 정식 값
- ~~`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)로 알려주세요
+- ~~`v0.6` — 인도식 lakh/crore 그룹핑 (`12,34,567`) + 로케일 기반 그룹 크기~~ ✅ 출시됨
+
+예정된 항목은 없습니다 — 폼에 필요한 기능이 빠져 있다면
+[이슈](https://github.com/devslab-kr/numkey/issues)로 알려주세요.
## 기여하기
diff --git a/README.md b/README.md
index 43164fe..2d494c8 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@ it does nothing:
| `data-numkey` | **the switch** — binds the input; the value is the max decimal places (empty = integer) |
| `data-numkey-negative` | allow a leading minus |
| `data-numkey-align="left"` | opt out of automatic right alignment |
-| `data-numkey-group="4"` | group size (default 3) |
+| `data-numkey-group="4"` | group size (default 3); `"3,2"` for Indian lakh — see below |
| `data-numkey-separator=" "` | group separator (default `,`) |
| `data-numkey-point=","` | decimal mark shown in the field (default `.`) |
| `data-numkey-locale` | derive separators from a locale — see below |
@@ -117,6 +117,26 @@ three cases. Since a plain form POST submits the *display* value, a form
using locales should post through `data-numkey-name` (below) or normalize
server-side.
+### Indian lakh/crore grouping
+
+The Indian system groups the last three digits and then every two —
+`12,34,56,789`, not `123,456,789`. Give `group` a `[primary, secondary]`
+pair, or just name the locale:
+
+```html
+
+
+```
+
+```ts
+format('10000000', { group: [3, 2] }) // '1,00,00,000' (1 crore)
+format('123456789', { locale: 'en-IN' }) // '12,34,56,789'
+```
+
+An explicit `group` always wins over the locale, and a single number stays
+uniform grouping (`4` → `1234,5678` for 만-style). `group: 0` turns
+separators off entirely.
+
### Korean amount reading (한글 금액 병기)
The "150만" hint that banking and fintech UIs render next to amount fields —
@@ -271,7 +291,7 @@ const [amount, setAmount] = createSignal('') // canonical
|---|---|---|
| `decimals` | `0` | max fraction digits (0 = integers only) |
| `negative` | `false` | allow a leading minus |
-| `group` | `3` | digits per group (4 for 만-style grouping) |
+| `group` | `3` | digits per group — `4` for 만-style, `[3, 2]` for Indian lakh, `0` for none |
| `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. |
@@ -325,8 +345,10 @@ const [amount, setAmount] = createSignal('') // canonical
- ~~`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
+- ~~`v0.6` — Indian lakh/crore grouping (`12,34,567`) + locale-derived group sizes~~ ✅ shipped
+
+Nothing else is planned — [open an issue](https://github.com/devslab-kr/numkey/issues)
+if your form needs something this doesn't do.
## Contributing
diff --git a/package.json b/package.json
index 60d0e95..71f9e74 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@devslab/numkey",
- "version": "0.5.0",
+ "version": "0.6.0",
"publishConfig": {
"access": "public"
},
diff --git a/site/index.html b/site/index.html
index fe96ed6..d46ec52 100644
--- a/site/index.html
+++ b/site/index.html
@@ -142,6 +142,7 @@
Locale (opt-in) / 로케일은 옵트인 — 기본값은
+
diff --git a/src/core.test.ts b/src/core.test.ts
index 29d30ad..183dd88 100644
--- a/src/core.test.ts
+++ b/src/core.test.ts
@@ -112,6 +112,37 @@ describe('format — canonical → display', () => {
).toBe('1.234.567,89')
})
+ it('groups the Indian way with [primary, secondary] sizes', () => {
+ const o = { group: [3, 2] as [number, number] }
+ expect(format('100000', o)).toBe('1,00,000') // 1 lakh
+ expect(format('10000000', o)).toBe('1,00,00,000') // 1 crore
+ expect(format('123456789', o)).toBe('12,34,56,789')
+ expect(format('1234', o)).toBe('1,234') // below the first cut
+ expect(format('999', o)).toBe('999')
+ })
+
+ it('matches Intl en-IN across integer widths', () => {
+ const nf = new Intl.NumberFormat('en-IN')
+ for (let width = 1; width <= 15; width++) {
+ const digits = '1234567890123456'.slice(0, width)
+ expect(format(digits, { group: [3, 2] })).toBe(nf.format(BigInt(digits)))
+ }
+ })
+
+ it('keeps negatives and decimals intact under Indian grouping', () => {
+ expect(
+ format('-12345678.9', { group: [3, 2], negative: true, decimals: 1 })
+ ).toBe('-1,23,45,678.9')
+ })
+
+ it('treats a secondary size of 0 as uniform grouping', () => {
+ expect(format('123456789', { group: [3, 0] })).toBe('123,456,789')
+ })
+
+ it('turns grouping off with group 0', () => {
+ expect(format('1234567', { group: 0 })).toBe('1234567')
+ })
+
it('round-trips: format(parse(x)) is stable', () => {
for (const v of ['1,234,567', '007', '0.50', '-9,999']) {
const o = { decimals: 2, negative: true }
@@ -125,14 +156,31 @@ describe('locale — opt-in separator derivation via Intl', () => {
it('derives separators from a BCP 47 tag', () => {
expect(localeSeparators('de-DE')).toEqual({
separator: '.',
- decimalPoint: ','
+ decimalPoint: ',',
+ group: [3, 3]
})
expect(localeSeparators('ko-KR')).toEqual({
separator: ',',
- decimalPoint: '.'
+ decimalPoint: '.',
+ group: [3, 3]
})
})
+ it('derives the Indian group sizes from the tag', () => {
+ expect(localeSeparators('en-IN')).toEqual({
+ separator: ',',
+ decimalPoint: '.',
+ group: [3, 2]
+ })
+ expect(format('123456789', { locale: 'en-IN' })).toBe('12,34,56,789')
+ })
+
+ it('explicit group wins over the locale', () => {
+ expect(format('123456789', { locale: 'en-IN', group: 3 })).toBe(
+ '123,456,789'
+ )
+ })
+
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')
@@ -148,7 +196,8 @@ describe('locale — opt-in separator derivation via Intl', () => {
it('falls back to deterministic defaults on an invalid tag', () => {
expect(localeSeparators('no-such-locale-tag-!!!')).toEqual({
separator: ',',
- decimalPoint: '.'
+ decimalPoint: '.',
+ group: [3, 3]
})
})
diff --git a/src/core.ts b/src/core.ts
index ae3026c..ad788b3 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -15,8 +15,13 @@ export interface NumkeyOptions {
decimals?: number
/** Allow a leading minus sign. Default false. */
negative?: boolean
- /** Digits per group in the integer part. Default 3 (use 4 for 만-style grouping). */
- group?: number
+ /**
+ * Digits per group in the integer part. Default 3 (use 4 for 만-style
+ * grouping). A `[primary, secondary]` pair groups the last `primary`
+ * digits and every `secondary` digits above them — the Indian system is
+ * `[3, 2]` (`12,34,567` — lakh and crore). 0 turns grouping off.
+ */
+ group?: number | [number, number]
/** Group separator in the display value. Default ','. */
separator?: string
/** Decimal mark in the display value. The canonical value always uses '.'. Default '.'. */
@@ -47,17 +52,37 @@ export interface NumkeyOptions {
type Resolved = Required
-const localeCache = new Map()
+export interface LocaleFormat {
+ separator: string
+ decimalPoint: string
+ /** `[primary, secondary]` group sizes — `[3, 3]` in most locales, `[3, 2]` in India. */
+ group: [number, number]
+}
+
+const localeCache = new Map()
/**
- * The group separator and decimal mark a locale uses ("de-DE" → `.` / `,`).
+ * Read the group sizes out of a formatted sample: the last group is the
+ * primary size and the one above it the secondary, which is how CLDR models
+ * grouping ("1,23,45,678" → `[3, 2]`).
+ */
+function groupSizesFromParts(
+ parts: Intl.NumberFormatPart[]
+): [number, number] {
+ const runs = parts.filter((p) => p.type === 'integer').map((p) => p.value.length)
+ if (runs.length < 2) return [3, 3]
+ const primary = runs[runs.length - 1] as number
+ const secondary = runs[runs.length - 2] as number
+ return [primary, secondary]
+}
+
+/**
+ * The group separator, decimal mark and group sizes a locale uses
+ * ("de-DE" → `.` / `,` / `[3,3]`, "en-IN" → `,` / `.` / `[3,2]`).
* "auto" (or empty) resolves the browser language; unknown tags and non-Intl
- * environments fall back to `,` / `.`.
+ * environments fall back to `,` / `.` / `[3,3]`.
*/
-export function localeSeparators(locale?: string): {
- separator: string
- decimalPoint: string
-} {
+export function localeSeparators(locale?: string): LocaleFormat {
const tag =
!locale || locale === 'auto'
? typeof navigator !== 'undefined'
@@ -68,12 +93,14 @@ export function localeSeparators(locale?: string): {
const cached = localeCache.get(key)
if (cached) return cached
- let out = { separator: ',', decimalPoint: '.' }
+ let out: LocaleFormat = { separator: ',', decimalPoint: '.', group: [3, 3] }
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 ?? '.'
+ decimalPoint: parts.find((p) => p.type === 'decimal')?.value ?? '.',
+ // 1234567.8 has enough integer groups to expose both sizes
+ group: groupSizesFromParts(parts)
}
} catch {
/* invalid tag → deterministic defaults */
@@ -85,15 +112,20 @@ export function localeSeparators(locale?: string): {
export function resolveOptions(opts?: NumkeyOptions): Resolved {
let separator = opts?.separator
let decimalPoint = opts?.decimalPoint
- if (opts?.locale && (separator === undefined || decimalPoint === undefined)) {
+ let group = opts?.group
+ if (
+ opts?.locale &&
+ (separator === undefined || decimalPoint === undefined || group === undefined)
+ ) {
const derived = localeSeparators(opts.locale)
separator ??= derived.separator
decimalPoint ??= derived.decimalPoint
+ group ??= derived.group
}
return {
decimals: opts?.decimals ?? 0,
negative: opts?.negative ?? false,
- group: opts?.group ?? 3,
+ group: group ?? 3,
separator: separator ?? ',',
decimalPoint: decimalPoint ?? '.',
locale: opts?.locale ?? '',
@@ -200,6 +232,42 @@ export function parse(input: string, opts?: NumkeyOptions): string {
return (neg ? '-' : '') + body
}
+/** `group` as a `[primary, secondary]` pair — a plain number means both. */
+export function groupSizes(
+ group: number | [number, number]
+): [number, number] {
+ if (typeof group === 'number') return [group, group]
+ const primary = group[0]
+ const secondary = group[1]
+ return [primary, secondary > 0 ? secondary : primary]
+}
+
+/**
+ * Insert separators right-to-left: the last `primary` digits form one group
+ * and everything above them is cut every `secondary` digits. With equal
+ * sizes this is ordinary thousands grouping; `[3, 2]` gives the Indian
+ * lakh/crore system (`12,34,567`).
+ */
+function groupInteger(
+ intPart: string,
+ group: number | [number, number],
+ separator: string
+): string {
+ const [primary, secondary] = groupSizes(group)
+ if (primary <= 0 || intPart.length <= primary) return intPart
+
+ const head = intPart.slice(0, intPart.length - primary)
+ const tail = intPart.slice(intPart.length - primary)
+ const parts: string[] = []
+ let i = head.length
+ while (i > secondary) {
+ parts.unshift(head.slice(i - secondary, i))
+ i -= secondary
+ }
+ parts.unshift(head.slice(0, i))
+ return parts.join(separator) + separator + tail
+}
+
/** Canonical → display: group separators in, display decimal mark. */
export function format(canonical: string, opts?: NumkeyOptions): string {
const o = resolveOptions(opts)
@@ -211,8 +279,7 @@ export function format(canonical: string, opts?: NumkeyOptions): string {
const intPart = pointIdx === -1 ? body : body.slice(0, pointIdx)
const fracPart = pointIdx === -1 ? null : body.slice(pointIdx + 1)
- const grouper = new RegExp(`\\B(?=(\\d{${o.group}})+(?!\\d))`, 'g')
- let out = (neg ? '-' : '') + intPart.replace(grouper, o.separator)
+ let out = (neg ? '-' : '') + groupInteger(intPart, o.group, o.separator)
if (fracPart !== null) out += o.decimalPoint + fracPart
return out
}
diff --git a/src/dom.test.ts b/src/dom.test.ts
index 1997d0a..e677f65 100644
--- a/src/dom.test.ts
+++ b/src/dom.test.ts
@@ -168,6 +168,54 @@ describe('data-numkey-locale — opt-in locale formatting', () => {
feed(el, '1234567,8')
expect(el.value).toBe('1.234.567,8')
})
+
+ it('picks up the Indian group sizes from the locale', () => {
+ const el = makeInput({ 'data-numkey': '', 'data-numkey-locale': 'en-IN' })
+ bind(el)
+ feed(el, '123456789')
+ expect(el.value).toBe('12,34,56,789')
+ })
+})
+
+describe('data-numkey-group — uniform and non-uniform grouping', () => {
+ it('takes a single size (만-style)', () => {
+ const el = makeInput({ 'data-numkey': '', 'data-numkey-group': '4' })
+ bind(el)
+ feed(el, '12345678')
+ expect(el.value).toBe('1234,5678')
+ })
+
+ it('takes a primary,secondary pair (Indian lakh)', () => {
+ const el = makeInput({ 'data-numkey': '', 'data-numkey-group': '3,2' })
+ bind(el)
+ feed(el, '123456789')
+ expect(el.value).toBe('12,34,56,789')
+ feed(el, '100000')
+ expect(el.value).toBe('1,00,000')
+ })
+
+ it('tolerates spacing and falls back on garbage', () => {
+ const spaced = makeInput({ 'data-numkey': '', 'data-numkey-group': ' 3 , 2 ' })
+ bind(spaced)
+ feed(spaced, '123456789')
+ expect(spaced.value).toBe('12,34,56,789')
+
+ const junk = makeInput({ 'data-numkey': '', 'data-numkey-group': 'abc' })
+ bind(junk)
+ feed(junk, '1234567')
+ expect(junk.value).toBe('1,234,567')
+ })
+
+ it('keeps the caret stable while typing under Indian grouping', () => {
+ const el = makeInput({ 'data-numkey': '', 'data-numkey-group': '3,2' })
+ bind(el)
+ // "12345" with the caret at the end → "12,345"; one more digit → "1,23,456"
+ feed(el, '12345', 5)
+ expect(el.value).toBe('12,345')
+ feed(el, '123456', 6)
+ expect(el.value).toBe('1,23,456')
+ expect(el.selectionStart).toBe(el.value.length)
+ })
})
describe('data-numkey-korean — live amount reading', () => {
diff --git a/src/dom.ts b/src/dom.ts
index 49a5d2e..9e5c8de 100644
--- a/src/dom.ts
+++ b/src/dom.ts
@@ -42,6 +42,22 @@ const SELECTOR = 'input[data-numkey]'
const unbinders = new WeakMap void>()
+/**
+ * `data-numkey-group` value → the `group` option. A single number is uniform
+ * grouping ("4" for 만-style); a `primary,secondary` pair is non-uniform
+ * ("3,2" for the Indian lakh system).
+ */
+function parseGroupAttr(value: string): number | [number, number] {
+ const parts = value.split(',').map((n) => parseInt(n.trim(), 10))
+ const primary = parts[0]
+ if (primary === undefined || Number.isNaN(primary)) return 3
+ const secondary = parts[1]
+ if (parts.length < 2 || secondary === undefined || Number.isNaN(secondary)) {
+ return primary || 3
+ }
+ return [primary, secondary]
+}
+
/** Read options from `data-numkey*` attributes. */
export function optionsFromElement(el: HTMLInputElement): NumkeyOptions {
const main = el.getAttribute('data-numkey')
@@ -56,7 +72,7 @@ export function optionsFromElement(el: HTMLInputElement): NumkeyOptions {
negative: el.hasAttribute('data-numkey-negative'),
koreanEntry: el.hasAttribute('data-numkey-korean-entry')
}
- if (group) opts.group = parseInt(group, 10) || 3
+ if (group) opts.group = parseGroupAttr(group)
if (separator !== null) opts.separator = separator
if (decimalPoint !== null) opts.decimalPoint = decimalPoint
if (locale) opts.locale = locale