@djangocfg/ui-core 2.1.543 → 2.1.544

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.543",
3
+ "version": "2.1.544",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -130,7 +130,7 @@
130
130
  "check:contrast": "node scripts/check-preset-contrast.mjs"
131
131
  },
132
132
  "peerDependencies": {
133
- "@djangocfg/i18n": "^2.1.543",
133
+ "@djangocfg/i18n": "^2.1.544",
134
134
  "consola": "^3.4.2",
135
135
  "lucide-react": "^0.545.0",
136
136
  "moment": "^2.30.1",
@@ -206,9 +206,9 @@
206
206
  "vaul": "1.1.2"
207
207
  },
208
208
  "devDependencies": {
209
- "@djangocfg/eslint-config": "^2.1.543",
210
- "@djangocfg/i18n": "^2.1.543",
211
- "@djangocfg/typescript-config": "^2.1.543",
209
+ "@djangocfg/eslint-config": "^2.1.544",
210
+ "@djangocfg/i18n": "^2.1.544",
211
+ "@djangocfg/typescript-config": "^2.1.544",
212
212
  "@storybook/react-vite": "^10.5.0",
213
213
  "@types/node": "^24.13.3",
214
214
  "@types/react": "19.2.15",
@@ -59,11 +59,34 @@ the dependency direction and may not depend on it.
59
59
  | `defaultValue` | `number` | Uncontrolled initial amount, minor units. |
60
60
  | `onValueChange` | `(minorUnits: number) => void` | |
61
61
  | `currency` | `string` | ISO 4217. Drives symbol, decimals, minor-unit factor. Default `USD`. |
62
+ | `fractionDigits` | `number` | Overrides the currency's decimals. `0` makes the field whole-unit — see below. |
62
63
  | `locale` | `string` | BCP 47. Defaults to the runtime's. |
63
64
  | `showSymbol` | `boolean` | Default `true`. Set false when the label carries the unit. |
64
65
  | `max` | `number` | Minor units. Entry beyond it is ignored, not clamped after the fact. |
65
66
  | `groupClassName` | `string` | Applied to the group, to size the whole control. |
66
67
 
68
+ ## Whole amounts
69
+
70
+ The default suits money that is paid — a price, an invoice, a charge. It does
71
+ not suit money that is merely large. On a property asking price the cents are
72
+ two zeros the reader looks past and, because entry is right-anchored, two
73
+ keystrokes they have to type: 1,400,000 costs nine digits, not seven.
74
+
75
+ `fractionDigits={0}` makes the field whole-unit:
76
+
77
+ ```tsx
78
+ const [asking, setAsking] = useState(1_400_000); // dollars, not cents
79
+
80
+ <MoneyField currency="USD" fractionDigits={0} value={asking} onValueChange={setAsking} />
81
+ // shows "1,400,000"; typing 1400000 reads as 1,400,000
82
+ ```
83
+
84
+ **The unit of `value` follows this prop.** Minor units at the default, major
85
+ units at `0` — the component divides by `10 ** fractionDigits`, so a caller
86
+ that changes the prop must change what it stores. This is deliberate: an
87
+ integer at any precision keeps the exactness the component exists for, and a
88
+ field that reported cents while displaying whole dollars would be a trap.
89
+
67
90
  ## Accessibility
68
91
 
69
92
  The currency symbol is `aria-hidden` — a screen reader announcing "dollar sign"
@@ -59,14 +59,22 @@ function currencySymbol(currency: string, locale: string | undefined): string {
59
59
  // Format / parse
60
60
  // =============================================================================
61
61
 
62
+ /** Minor units per major unit for a given precision — 100 at two digits, 1 at none. */
63
+ function factorFor(digits: number): number {
64
+ return 10 ** digits;
65
+ }
66
+
62
67
  /** Format minor units for display, grouped and with the locale's separators. */
63
- function formatMinor(minor: number, currency: string, locale: string | undefined): string {
64
- const digits = currencyFractionDigits(currency);
68
+ function formatMinor(
69
+ minor: number,
70
+ digits: number,
71
+ locale: string | undefined,
72
+ ): string {
65
73
  return new Intl.NumberFormat(locale, {
66
74
  minimumFractionDigits: digits,
67
75
  maximumFractionDigits: digits,
68
76
  useGrouping: true,
69
- }).format(minor / minorUnitFactor(currency));
77
+ }).format(minor / factorFor(digits));
70
78
  }
71
79
 
72
80
  /**
@@ -77,7 +85,7 @@ function formatMinor(minor: number, currency: string, locale: string | undefined
77
85
  * entry people expect from a money field, and the reason the caret never has
78
86
  * to sit "before the decimal point".
79
87
  */
80
- function parseToMinor(raw: string, _currency: string): number {
88
+ function parseToMinor(raw: string): number {
81
89
  const digits = raw.replace(/\D/g, '');
82
90
  if (!digits) return 0;
83
91
  // Number, not parseInt: 16 digits of cents is past MAX_SAFE_INTEGER, and
@@ -133,6 +141,21 @@ export interface MoneyFieldProps
133
141
  onValueChange?: (minorUnits: number) => void;
134
142
  /** ISO 4217 code — drives the symbol, the decimals and the minor-unit factor. */
135
143
  currency?: string;
144
+ /**
145
+ * Digits after the decimal separator, overriding the currency's own.
146
+ *
147
+ * The default is right for money that is paid: a price, an invoice, a
148
+ * charge. It is wrong for money that is merely large — a property asking
149
+ * price, a valuation, a budget — where the cents are always two zeros the
150
+ * reader has to look past and two keystrokes they have to type, since entry
151
+ * is right-anchored.
152
+ *
153
+ * `0` makes the field whole-unit: `value` is then dollars rather than cents,
154
+ * and typing "1400000" reads as 1,400,000. The unit of `value` always
155
+ * follows this — minor units at 2, major units at 0 — so a caller that
156
+ * changes it must change what it stores.
157
+ */
158
+ fractionDigits?: number;
136
159
  /** BCP 47 tag for separators and symbol placement. Defaults to the runtime's. */
137
160
  locale?: string;
138
161
  /** Show the currency symbol before the input. Set false when the label carries it. */
@@ -177,6 +200,7 @@ const MoneyField = React.forwardRef<HTMLInputElement, MoneyFieldProps>(
177
200
  defaultValue,
178
201
  onValueChange,
179
202
  currency = 'USD',
203
+ fractionDigits,
180
204
  locale,
181
205
  showSymbol = true,
182
206
  max,
@@ -195,9 +219,11 @@ const MoneyField = React.forwardRef<HTMLInputElement, MoneyFieldProps>(
195
219
  const [internal, setInternal] = React.useState<number>(defaultValue ?? 0);
196
220
  const minor = isControlled ? value : internal;
197
221
 
222
+ const digits = fractionDigits ?? currencyFractionDigits(currency);
223
+
198
224
  const display = React.useMemo(
199
- () => formatMinor(minor, currency, locale),
200
- [minor, currency, locale],
225
+ () => formatMinor(minor, digits, locale),
226
+ [minor, digits, locale],
201
227
  );
202
228
  const symbol = React.useMemo(
203
229
  () => currencySymbol(currency, locale),
@@ -230,7 +256,7 @@ const MoneyField = React.forwardRef<HTMLInputElement, MoneyFieldProps>(
230
256
  const typed = el.value;
231
257
  const caret = el.selectionStart ?? typed.length;
232
258
 
233
- let next = parseToMinor(typed, currency);
259
+ let next = parseToMinor(typed);
234
260
  if (max !== undefined && next > max) next = max;
235
261
 
236
262
  // Anchor on digits to the LEFT of the caret, which regrouping cannot
@@ -242,12 +268,12 @@ const MoneyField = React.forwardRef<HTMLInputElement, MoneyFieldProps>(
242
268
  typed.length < display.length && digitsLeft === digitsBefore(display, caret + 1);
243
269
  if (deletedSeparator && digitsLeft > 0) digitsLeft -= 1;
244
270
 
245
- const nextDisplay = formatMinor(next, currency, locale);
271
+ const nextDisplay = formatMinor(next, digits, locale);
246
272
  pendingCaretRef.current = offsetAfterDigits(nextDisplay, digitsLeft);
247
273
 
248
274
  commit(next);
249
275
  },
250
- [commit, currency, display, locale, max],
276
+ [commit, digits, display, locale, max],
251
277
  );
252
278
 
253
279
  return (