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