@djangocfg/ui-core 2.1.541 → 2.1.542
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/README.md +3 -1
- package/package.json +12 -9
- package/src/components/data/BalancedText/hooks/useMaxLinesWidth.ts +4 -25
- package/src/components/forms/button-download/index.tsx +1 -1
- package/src/components/forms/datetime-field/date-time-field.tsx +1 -1
- package/src/components/forms/editable/index.tsx +7 -3
- package/src/components/forms/input-group/index.tsx +12 -0
- package/src/components/forms/mask-input/index.tsx +7 -3
- package/src/components/forms/money-field/README.md +79 -0
- package/src/components/forms/money-field/index.tsx +288 -0
- package/src/components/forms/otp/use-otp-input.ts +1 -1
- package/src/components/forms/tags-input/index.tsx +55 -41
- package/src/components/forms/time-picker/index.tsx +7 -3
- package/src/components/index.ts +4 -0
- package/src/components/layout/key-value/index.tsx +9 -7
- package/src/components/layout/resizable/index.tsx +6 -1
- package/src/components/navigation/command/index.tsx +24 -6
- package/src/components/navigation/link/LinkContext.tsx +3 -1
- package/src/components/navigation/pagination/pagination-static.tsx +1 -1
- package/src/components/navigation/tabs/index.tsx +30 -9
- package/src/components/overlay/responsive-sheet/index.tsx +4 -4
- package/src/components/select/helpers.tsx +1 -1
- package/src/components/select/multi-select-pro-async.tsx +13 -6
- package/src/components/select/multi-select-pro.tsx +3 -4
- package/src/components/specialized/flag/Flag.tsx +11 -5
- package/src/components/specialized/flag/flag-map.ts +13 -6
- package/src/components/specialized/image-with-fallback/index.tsx +9 -4
- package/src/components/specialized/presence/index.tsx +2 -3
- package/src/components/specialized/token-icon/index.tsx +26 -13
- package/src/hooks/audio/useAudioPrefs.ts +8 -3
- package/src/hooks/device/useBrowserDetect.ts +5 -1
- package/src/hooks/dom/useImageLoader.ts +24 -20
- package/src/hooks/dom/useScroll.ts +7 -6
- package/src/hooks/events/useEventsBus.ts +19 -5
- package/src/hooks/hotkey/useHotkeyChord.ts +11 -4
- package/src/hooks/hotkey/useHotkeyHelp.ts +8 -3
- package/src/hooks/router/adapter.tsx +3 -1
- package/src/hooks/state/storage-quota.ts +27 -0
- package/src/hooks/state/useDebouncedCallback.ts +26 -20
- package/src/hooks/state/useLocalStorage.ts +7 -13
- package/src/hooks/state/useSessionStorage.ts +7 -9
- package/src/lib/compose-event-handlers.ts +5 -5
- package/src/lib/dialog-service/getDialog.ts +1 -1
- package/src/lib/get-element-ref.ts +9 -6
- package/src/lib/pretext/pretext.types.ts +25 -70
- package/src/lib/pretext/use-pretext.ts +8 -12
- package/src/snippets/LazyComponent.tsx +9 -9
- package/src/styles/palette/useThemePalette.ts +7 -0
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ import { UiProviders, Button, Card } from '@djangocfg/ui-core';
|
|
|
51
51
|
| Group | Examples |
|
|
52
52
|
|---|---|
|
|
53
53
|
| `components/data/` | Avatar · Badge · Card · Table · BalancedText · Skeleton |
|
|
54
|
-
| `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form · DateField · TimeField · DateTimeField |
|
|
54
|
+
| `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form · DateField · TimeField · DateTimeField · MoneyField |
|
|
55
55
|
| `components/feedback/` | Alert · Toast · Banner · Progress · Spinner |
|
|
56
56
|
| `components/overlay/` | Dialog · Drawer · Popover · Tooltip · HoverCard · Sheet · ContextMenu · DropdownMenu |
|
|
57
57
|
| `components/navigation/` | Sidebar · Tabs · Breadcrumb · Pagination · NavigationMenu · Command · Disclosure |
|
|
@@ -64,6 +64,8 @@ import { UiProviders, Button, Card } from '@djangocfg/ui-core';
|
|
|
64
64
|
Imports stay flat — group folders are organisational. Native-engine date/time
|
|
65
65
|
fields (`DateField` / `TimeField` / `DateTimeField`) have their own reference in
|
|
66
66
|
[`forms/datetime-field/README.md`](src/components/forms/datetime-field/README.md).
|
|
67
|
+
`MoneyField` carries an amount as integer minor units, not a float — see
|
|
68
|
+
[`forms/money-field/README.md`](src/components/forms/money-field/README.md).
|
|
67
69
|
|
|
68
70
|
## Hooks (`/hooks`)
|
|
69
71
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.542",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -119,6 +119,8 @@
|
|
|
119
119
|
"files": [
|
|
120
120
|
"dist",
|
|
121
121
|
"src",
|
|
122
|
+
"!src/**/*.stories.tsx",
|
|
123
|
+
"!src/components/_gallery.tsx",
|
|
122
124
|
"README.md",
|
|
123
125
|
"LICENSE"
|
|
124
126
|
],
|
|
@@ -128,14 +130,14 @@
|
|
|
128
130
|
"check:contrast": "node scripts/check-preset-contrast.mjs"
|
|
129
131
|
},
|
|
130
132
|
"peerDependencies": {
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
133
|
+
"@djangocfg/i18n": "^2.1.542",
|
|
132
134
|
"consola": "^3.4.2",
|
|
133
135
|
"lucide-react": "^0.545.0",
|
|
134
136
|
"moment": "^2.30.1",
|
|
135
137
|
"next": ">=14.0.0",
|
|
136
|
-
"react": "^19.
|
|
138
|
+
"react": "^19.0.0",
|
|
137
139
|
"react-device-detect": "^2.2.3",
|
|
138
|
-
"react-dom": "^19.
|
|
140
|
+
"react-dom": "^19.0.0",
|
|
139
141
|
"react-hook-form": "^7.69.0",
|
|
140
142
|
"react-router": ">=7",
|
|
141
143
|
"tailwindcss": "^4.1.18",
|
|
@@ -151,6 +153,7 @@
|
|
|
151
153
|
}
|
|
152
154
|
},
|
|
153
155
|
"dependencies": {
|
|
156
|
+
"@chenglou/pretext": "^0.0.8",
|
|
154
157
|
"@hookform/resolvers": "^5.4.0",
|
|
155
158
|
"@radix-ui/react-accordion": "^1.2.16",
|
|
156
159
|
"@radix-ui/react-alert-dialog": "^1.1.19",
|
|
@@ -202,17 +205,17 @@
|
|
|
202
205
|
"tailwind-merge": "^3.6.0",
|
|
203
206
|
"vaul": "1.1.2"
|
|
204
207
|
},
|
|
205
|
-
"optionalDependencies": {
|
|
206
|
-
"@chenglou/pretext": "^0.0.8"
|
|
207
|
-
},
|
|
208
208
|
"devDependencies": {
|
|
209
|
-
"@djangocfg/
|
|
210
|
-
"@djangocfg/
|
|
209
|
+
"@djangocfg/eslint-config": "^2.1.542",
|
|
210
|
+
"@djangocfg/i18n": "^2.1.542",
|
|
211
|
+
"@djangocfg/typescript-config": "^2.1.542",
|
|
212
|
+
"@storybook/react-vite": "^10.5.0",
|
|
211
213
|
"@types/node": "^24.13.3",
|
|
212
214
|
"@types/react": "19.2.15",
|
|
213
215
|
"@types/react-dom": "19.2.3",
|
|
214
216
|
"lucide-react": "^0.545.0",
|
|
215
217
|
"next": "^16.2.10",
|
|
218
|
+
"storybook": "^10.5.0",
|
|
216
219
|
"typescript": "^5.9.3"
|
|
217
220
|
},
|
|
218
221
|
"publishConfig": {
|
|
@@ -8,32 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
'use client';
|
|
10
10
|
|
|
11
|
+
import { walkLineRanges } from '@chenglou/pretext';
|
|
11
12
|
import * as React from 'react';
|
|
12
13
|
import type { PreparedTextWithSegments } from '../../../../lib/pretext';
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
interface WalkLineRange {
|
|
18
|
-
width: number;
|
|
19
|
-
start: number;
|
|
20
|
-
end: number;
|
|
21
|
-
}
|
|
22
|
-
interface PretextWalker {
|
|
23
|
-
walkLineRanges(
|
|
24
|
-
prepared: PreparedTextWithSegments,
|
|
25
|
-
maxWidth: number,
|
|
26
|
-
visit: (line: WalkLineRange) => void,
|
|
27
|
-
): void;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
let cached: PretextWalker | null = null;
|
|
31
|
-
function getPretext(): PretextWalker {
|
|
32
|
-
if (cached) return cached;
|
|
33
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
-
cached = require('@chenglou/pretext') as PretextWalker;
|
|
35
|
-
return cached;
|
|
36
|
-
}
|
|
15
|
+
// Imported statically: `@chenglou/pretext` is ESM-only, so the previous lazy
|
|
16
|
+
// `require` resolved in no runtime — under Vite/ESM `require` is not defined
|
|
17
|
+
// and the first render threw `require is not defined`.
|
|
37
18
|
|
|
38
19
|
export function useMaxLinesWidth(
|
|
39
20
|
prepared: PreparedTextWithSegments | null,
|
|
@@ -43,8 +24,6 @@ export function useMaxLinesWidth(
|
|
|
43
24
|
return React.useMemo(() => {
|
|
44
25
|
if (!prepared || !maxLines || maxLines <= 0 || maxWidth <= 0) return 0;
|
|
45
26
|
|
|
46
|
-
const { walkLineRanges } = getPretext();
|
|
47
|
-
|
|
48
27
|
// Natural line count at maxWidth. If it's already within the cap, defer
|
|
49
28
|
// to `useBalancedWidth` (returning 0 means "don't use my value").
|
|
50
29
|
let naturalCount = 0;
|
|
@@ -162,7 +162,7 @@ export const DateTimeField = React.forwardRef<HTMLInputElement, DateTimeFieldPro
|
|
|
162
162
|
// flip the whole popover ABOVE the field when there's no room below.
|
|
163
163
|
// `collisionPadding` just keeps it off the very edge.
|
|
164
164
|
className="w-auto p-0"
|
|
165
|
-
align=
|
|
165
|
+
align={align}
|
|
166
166
|
collisionPadding={12}
|
|
167
167
|
>
|
|
168
168
|
<Calendar
|
|
@@ -41,14 +41,14 @@ export interface EditableRootProps
|
|
|
41
41
|
children?: React.ReactNode;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
export
|
|
44
|
+
export type EditablePreviewProps = React.ComponentPropsWithoutRef<"span">;
|
|
45
45
|
|
|
46
46
|
export interface EditableInputProps extends Omit<React.ComponentPropsWithoutRef<"input">, "value" | "defaultValue"> {
|
|
47
47
|
/** Density — matches the standalone Input's `inputSize`. Default: 'default'. */
|
|
48
48
|
inputSize?: 'default' | 'sm';
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export
|
|
51
|
+
export type EditableTextareaProps = Omit<React.ComponentPropsWithoutRef<"textarea">, "value" | "defaultValue">;
|
|
52
52
|
|
|
53
53
|
// =============================================================================
|
|
54
54
|
// Context
|
|
@@ -163,7 +163,11 @@ const Editable = React.forwardRef<HTMLDivElement, EditableRootProps>(
|
|
|
163
163
|
setEditing(false);
|
|
164
164
|
}, [setValue, onValueCancel, setEditing]);
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
// NOT exposed on the context below — consumers enter edit mode through
|
|
167
|
+
// `setIsEditing(true)`, which skips this snapshot of `previousValueRef`.
|
|
168
|
+
// Kept because it documents the intended enter-edit path; wiring it up is a
|
|
169
|
+
// behaviour change (cancel would then restore the value at edit-start).
|
|
170
|
+
const _startEditing = React.useCallback(() => {
|
|
167
171
|
if (disabled || readOnly) return;
|
|
168
172
|
previousValueRef.current = resolvedValue;
|
|
169
173
|
setEditing(true);
|
|
@@ -34,8 +34,20 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
34
34
|
"h-10 has-[>textarea]:h-auto",
|
|
35
35
|
|
|
36
36
|
// Variants based on alignment.
|
|
37
|
+
//
|
|
38
|
+
// Each side gets its inset from EITHER the addon or the control, never
|
|
39
|
+
// both and never neither. With an addon the addon supplies the edge
|
|
40
|
+
// gap and the control only needs to clear it (pl-2); with no addon the
|
|
41
|
+
// control has to stand in for it (pl-3, matching `Input`'s own px-3).
|
|
42
|
+
//
|
|
43
|
+
// The `not-has-` half is load-bearing: `bare` strips the control's
|
|
44
|
+
// padding, so a group without an addon on a side used to leave its text
|
|
45
|
+
// flush against the border — visible on any addon-less group, and on a
|
|
46
|
+
// MoneyField with `showSymbol={false}`.
|
|
37
47
|
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
|
48
|
+
"not-has-[>[data-align=inline-start]]:[&>input]:pl-3",
|
|
38
49
|
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
|
50
|
+
"not-has-[>[data-align=inline-end]]:[&>input]:pr-3",
|
|
39
51
|
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
|
40
52
|
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
|
41
53
|
|
|
@@ -237,7 +237,11 @@ const MaskInput = React.forwardRef<HTMLInputElement, MaskInputProps>(
|
|
|
237
237
|
if (!data) return;
|
|
238
238
|
|
|
239
239
|
const start = input.selectionStart ?? 0;
|
|
240
|
-
|
|
240
|
+
// NOTE: only the selection START is consulted. A non-collapsed
|
|
241
|
+
// selection (`selectionEnd !== start`) is validated as if the caret sat
|
|
242
|
+
// at its left edge, so typing over a selection checks the wrong mask
|
|
243
|
+
// slot. handleKeyDown below does handle `start !== end`; this path does
|
|
244
|
+
// not. Left as-is — fixing it changes input behaviour.
|
|
241
245
|
|
|
242
246
|
// Determine which mask position we're at
|
|
243
247
|
let maskPos = 0;
|
|
@@ -382,7 +386,7 @@ const MaskInput = React.forwardRef<HTMLInputElement, MaskInputProps>(
|
|
|
382
386
|
updateValue(newRaw);
|
|
383
387
|
}
|
|
384
388
|
},
|
|
385
|
-
[maskParts,
|
|
389
|
+
[maskParts, onKeyDown, updateValue]
|
|
386
390
|
);
|
|
387
391
|
|
|
388
392
|
const computeNextEmptyCaret = React.useCallback(() => {
|
|
@@ -424,7 +428,7 @@ const MaskInput = React.forwardRef<HTMLInputElement, MaskInputProps>(
|
|
|
424
428
|
setIsFocused(false);
|
|
425
429
|
onBlur?.(event);
|
|
426
430
|
if (cleanOnBlur) {
|
|
427
|
-
const { complete
|
|
431
|
+
const { complete } = applyMask(rawValue, maskParts, maskChar);
|
|
428
432
|
if (!complete) {
|
|
429
433
|
updateValue("");
|
|
430
434
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# MoneyField
|
|
2
|
+
|
|
3
|
+
An amount input that formats as you type and reports **minor units** — cents,
|
|
4
|
+
not dollars.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
const [price, setPrice] = useState(1999); // $19.99
|
|
8
|
+
|
|
9
|
+
<MoneyField currency="USD" value={price} onValueChange={setPrice} />
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## The value is an integer
|
|
13
|
+
|
|
14
|
+
`value` and `onValueChange` carry minor units. A field that hands back `19.99`
|
|
15
|
+
hands back a number that does not exist in binary floating point: totals drift
|
|
16
|
+
by a cent, and the bug surfaces in an invoice rather than in a test. `1999` is
|
|
17
|
+
exact.
|
|
18
|
+
|
|
19
|
+
This matches `@djangocfg/payments`, which moves `MinorUnits` for the same
|
|
20
|
+
reason — a value goes from this field to a charge with no conversion between.
|
|
21
|
+
|
|
22
|
+
## Entry is till-style
|
|
23
|
+
|
|
24
|
+
Digits fill from the right: `1` `9` `9` reads $1.99. The caret never has to sit
|
|
25
|
+
"before the decimal point", which is the state that makes most currency inputs
|
|
26
|
+
confusing.
|
|
27
|
+
|
|
28
|
+
Every keystroke rewrites the whole display, so the caret is restored by **digit
|
|
29
|
+
count, not string offset**. Inserting a thousands separator shifts every
|
|
30
|
+
character after it, and an offset-based restore moves the caret a place — the
|
|
31
|
+
single most common defect in a currency input.
|
|
32
|
+
|
|
33
|
+
## Separators come from `Intl`
|
|
34
|
+
|
|
35
|
+
The decimal mark is a comma across most of Europe. Guessing it means `1.234` is
|
|
36
|
+
read as either 1.234 or 1234 depending on the user, with nothing on screen to
|
|
37
|
+
say which happened. Pass `locale` to pin it; otherwise the runtime's is used.
|
|
38
|
+
|
|
39
|
+
## Zero-decimal currencies
|
|
40
|
+
|
|
41
|
+
¥500 is five hundred yen, not five. `currencyFractionDigits()` and
|
|
42
|
+
`minorUnitFactor()` are exported for callers that need the same rule:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
minorUnitFactor('USD') // 100
|
|
46
|
+
minorUnitFactor('JPY') // 1
|
|
47
|
+
currencyFractionDigits('JPY') // 0
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The zero-decimal list mirrors `@djangocfg/payments`' `domain/money.ts`. It is
|
|
51
|
+
duplicated rather than imported because `ui-core` sits **below** `payments` in
|
|
52
|
+
the dependency direction and may not depend on it.
|
|
53
|
+
|
|
54
|
+
## Props
|
|
55
|
+
|
|
56
|
+
| Prop | Type | Notes |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `value` | `number` | Amount in minor units (controlled). |
|
|
59
|
+
| `defaultValue` | `number` | Uncontrolled initial amount, minor units. |
|
|
60
|
+
| `onValueChange` | `(minorUnits: number) => void` | |
|
|
61
|
+
| `currency` | `string` | ISO 4217. Drives symbol, decimals, minor-unit factor. Default `USD`. |
|
|
62
|
+
| `locale` | `string` | BCP 47. Defaults to the runtime's. |
|
|
63
|
+
| `showSymbol` | `boolean` | Default `true`. Set false when the label carries the unit. |
|
|
64
|
+
| `max` | `number` | Minor units. Entry beyond it is ignored, not clamped after the fact. |
|
|
65
|
+
| `groupClassName` | `string` | Applied to the group, to size the whole control. |
|
|
66
|
+
|
|
67
|
+
## Accessibility
|
|
68
|
+
|
|
69
|
+
The currency symbol is `aria-hidden` — a screen reader announcing "dollar sign"
|
|
70
|
+
on entry is noise. The accessible name has to come from a label you associate
|
|
71
|
+
yourself:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
<label htmlFor={id}>Price</label>
|
|
75
|
+
<MoneyField id={id} … />
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Text is left-aligned so it hugs the symbol. Right alignment belongs to a column
|
|
79
|
+
of amounts in a table; pass `className="text-right"` there.
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../../lib/utils';
|
|
6
|
+
import { InputGroup, InputGroupAddon, InputGroupInput } from '../input-group';
|
|
7
|
+
|
|
8
|
+
// =============================================================================
|
|
9
|
+
// Money model
|
|
10
|
+
// =============================================================================
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Currencies with no minor unit — ¥500 is five hundred yen, not five yen.
|
|
14
|
+
*
|
|
15
|
+
* Formatting one of these with two decimals inflates every amount by 100×,
|
|
16
|
+
* which reads as a plausible price rather than as an error. The list mirrors
|
|
17
|
+
* `@djangocfg/payments`' `domain/money.ts`; it is duplicated rather than
|
|
18
|
+
* imported because `ui-core` sits BELOW `payments` and may not depend on it.
|
|
19
|
+
*/
|
|
20
|
+
const ZERO_DECIMAL_CURRENCIES = new Set([
|
|
21
|
+
'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA',
|
|
22
|
+
'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/** Minor units per major unit for a currency — 100 for USD, 1 for JPY. */
|
|
26
|
+
export function minorUnitFactor(currency: string): number {
|
|
27
|
+
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 1 : 100;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Digits after the decimal separator for a currency. */
|
|
31
|
+
export function currencyFractionDigits(currency: string): number {
|
|
32
|
+
return ZERO_DECIMAL_CURRENCIES.has(currency.toUpperCase()) ? 0 : 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// Locale probing
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
39
|
+
// A `localeParts()` helper used to probe the group/decimal separators by hand.
|
|
40
|
+
// It is gone: nothing parses separators any more. Input is digits-only
|
|
41
|
+
// (`parseToMinor`) and output goes straight through `Intl.NumberFormat`
|
|
42
|
+
// (`formatMinor`), so the locale's separators never have to be named.
|
|
43
|
+
|
|
44
|
+
/** The currency's symbol in this locale, e.g. "$", "€", "¥". */
|
|
45
|
+
function currencySymbol(currency: string, locale: string | undefined): string {
|
|
46
|
+
try {
|
|
47
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
48
|
+
style: 'currency',
|
|
49
|
+
currency: currency.toUpperCase(),
|
|
50
|
+
}).formatToParts(0);
|
|
51
|
+
return parts.find((p) => p.type === 'currency')?.value ?? currency.toUpperCase();
|
|
52
|
+
} catch {
|
|
53
|
+
// Unknown code — show it as-is rather than throwing inside a render.
|
|
54
|
+
return currency.toUpperCase();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Format / parse
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
/** 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);
|
|
65
|
+
return new Intl.NumberFormat(locale, {
|
|
66
|
+
minimumFractionDigits: digits,
|
|
67
|
+
maximumFractionDigits: digits,
|
|
68
|
+
useGrouping: true,
|
|
69
|
+
}).format(minor / minorUnitFactor(currency));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read minor units out of whatever the user typed.
|
|
74
|
+
*
|
|
75
|
+
* Digits-only, right-anchored: the last `fractionDigits` digits are the minor
|
|
76
|
+
* part. Typing "5" in USD means $0.05, "550" means $5.50 — the till-style
|
|
77
|
+
* entry people expect from a money field, and the reason the caret never has
|
|
78
|
+
* to sit "before the decimal point".
|
|
79
|
+
*/
|
|
80
|
+
function parseToMinor(raw: string, _currency: string): number {
|
|
81
|
+
const digits = raw.replace(/\D/g, '');
|
|
82
|
+
if (!digits) return 0;
|
|
83
|
+
// Number, not parseInt: 16 digits of cents is past MAX_SAFE_INTEGER, and
|
|
84
|
+
// silently truncating someone's amount is worse than clamping it.
|
|
85
|
+
const value = Number(digits);
|
|
86
|
+
return Number.isFinite(value) ? value : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Count digits in `text` up to `index` — the caret anchor that survives regrouping. */
|
|
90
|
+
function digitsBefore(text: string, index: number): number {
|
|
91
|
+
let n = 0;
|
|
92
|
+
for (let i = 0; i < index && i < text.length; i += 1) {
|
|
93
|
+
if (text[i] !== undefined && /\d/.test(text[i] as string)) n += 1;
|
|
94
|
+
}
|
|
95
|
+
return n;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Inverse of `digitsBefore`: the offset just after the nth digit. */
|
|
99
|
+
function offsetAfterDigits(text: string, digitCount: number): number {
|
|
100
|
+
if (digitCount <= 0) return 0;
|
|
101
|
+
let seen = 0;
|
|
102
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
103
|
+
if (text[i] !== undefined && /\d/.test(text[i] as string)) {
|
|
104
|
+
seen += 1;
|
|
105
|
+
if (seen === digitCount) return i + 1;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return text.length;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// Component
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
export interface MoneyFieldProps
|
|
116
|
+
extends Omit<
|
|
117
|
+
React.ComponentProps<'input'>,
|
|
118
|
+
'value' | 'defaultValue' | 'onChange' | 'prefix' | 'type'
|
|
119
|
+
> {
|
|
120
|
+
/**
|
|
121
|
+
* Amount in MINOR UNITS — cents, not dollars.
|
|
122
|
+
*
|
|
123
|
+
* Money never crosses a boundary as a float here. `19.99` is not
|
|
124
|
+
* representable in binary floating point, so a column of them drifts; `1999`
|
|
125
|
+
* is exact. This matches `@djangocfg/payments`, which moves `MinorUnits`
|
|
126
|
+
* for the same reason, so a value goes from this field to a charge with no
|
|
127
|
+
* conversion in between.
|
|
128
|
+
*/
|
|
129
|
+
value?: number;
|
|
130
|
+
/** Uncontrolled initial amount, also in minor units. */
|
|
131
|
+
defaultValue?: number;
|
|
132
|
+
/** Called with the new amount in minor units. */
|
|
133
|
+
onValueChange?: (minorUnits: number) => void;
|
|
134
|
+
/** ISO 4217 code — drives the symbol, the decimals and the minor-unit factor. */
|
|
135
|
+
currency?: string;
|
|
136
|
+
/** BCP 47 tag for separators and symbol placement. Defaults to the runtime's. */
|
|
137
|
+
locale?: string;
|
|
138
|
+
/** Show the currency symbol before the input. Set false when the label carries it. */
|
|
139
|
+
showSymbol?: boolean;
|
|
140
|
+
/** Largest accepted amount, in minor units. Entry beyond it is ignored. */
|
|
141
|
+
max?: number;
|
|
142
|
+
/** Applied to the group, so callers can size the whole control. */
|
|
143
|
+
groupClassName?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* An amount input that formats as you type and reports MINOR UNITS.
|
|
148
|
+
*
|
|
149
|
+
* ```tsx
|
|
150
|
+
* const [price, setPrice] = React.useState(1999); // $19.99
|
|
151
|
+
* <MoneyField currency="USD" value={price} onValueChange={setPrice} />
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* Three decisions worth knowing, because each is a common way this component
|
|
155
|
+
* is got wrong:
|
|
156
|
+
*
|
|
157
|
+
* **The value is an integer.** A money field that hands back `19.99` hands
|
|
158
|
+
* back a number that does not exist in binary floating point. Totals drift by
|
|
159
|
+
* a cent, and the bug surfaces in an invoice rather than in a test.
|
|
160
|
+
*
|
|
161
|
+
* **Entry is till-style and right-anchored.** Every keystroke rewrites the
|
|
162
|
+
* whole display, so digits fill from the right: "1", "19", "199" → $1.99. The
|
|
163
|
+
* caret is then restored by DIGIT COUNT, not by string offset — inserting a
|
|
164
|
+
* thousands separator shifts every character after it, and an offset-based
|
|
165
|
+
* restore is exactly why so many currency inputs jump the cursor when a
|
|
166
|
+
* number crosses 1,000.
|
|
167
|
+
*
|
|
168
|
+
* **Separators come from `Intl`, not from a constant.** The decimal mark is a
|
|
169
|
+
* comma across most of Europe. Guessing it means "1.234" is read as either
|
|
170
|
+
* 1.234 or 1234 depending on where the user is, with nothing on screen to say
|
|
171
|
+
* which happened.
|
|
172
|
+
*/
|
|
173
|
+
const MoneyField = React.forwardRef<HTMLInputElement, MoneyFieldProps>(
|
|
174
|
+
(
|
|
175
|
+
{
|
|
176
|
+
value,
|
|
177
|
+
defaultValue,
|
|
178
|
+
onValueChange,
|
|
179
|
+
currency = 'USD',
|
|
180
|
+
locale,
|
|
181
|
+
showSymbol = true,
|
|
182
|
+
max,
|
|
183
|
+
className,
|
|
184
|
+
groupClassName,
|
|
185
|
+
onBlur,
|
|
186
|
+
disabled,
|
|
187
|
+
...props
|
|
188
|
+
},
|
|
189
|
+
forwardedRef,
|
|
190
|
+
) => {
|
|
191
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
192
|
+
React.useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);
|
|
193
|
+
|
|
194
|
+
const isControlled = value !== undefined;
|
|
195
|
+
const [internal, setInternal] = React.useState<number>(defaultValue ?? 0);
|
|
196
|
+
const minor = isControlled ? value : internal;
|
|
197
|
+
|
|
198
|
+
const display = React.useMemo(
|
|
199
|
+
() => formatMinor(minor, currency, locale),
|
|
200
|
+
[minor, currency, locale],
|
|
201
|
+
);
|
|
202
|
+
const symbol = React.useMemo(
|
|
203
|
+
() => currencySymbol(currency, locale),
|
|
204
|
+
[currency, locale],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// A controlled input commits the caret to end-of-string on every rewrite,
|
|
208
|
+
// so the position is recorded here during the change and applied after the
|
|
209
|
+
// render lands. Same shape as `MaskInput`, for the same reason.
|
|
210
|
+
const pendingCaretRef = React.useRef<number | null>(null);
|
|
211
|
+
|
|
212
|
+
React.useLayoutEffect(() => {
|
|
213
|
+
const pos = pendingCaretRef.current;
|
|
214
|
+
if (pos == null) return;
|
|
215
|
+
pendingCaretRef.current = null;
|
|
216
|
+
inputRef.current?.setSelectionRange(pos, pos);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const commit = React.useCallback(
|
|
220
|
+
(next: number) => {
|
|
221
|
+
if (!isControlled) setInternal(next);
|
|
222
|
+
onValueChange?.(next);
|
|
223
|
+
},
|
|
224
|
+
[isControlled, onValueChange],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const handleChange = React.useCallback(
|
|
228
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
229
|
+
const el = event.target;
|
|
230
|
+
const typed = el.value;
|
|
231
|
+
const caret = el.selectionStart ?? typed.length;
|
|
232
|
+
|
|
233
|
+
let next = parseToMinor(typed, currency);
|
|
234
|
+
if (max !== undefined && next > max) next = max;
|
|
235
|
+
|
|
236
|
+
// Anchor on digits to the LEFT of the caret, which regrouping cannot
|
|
237
|
+
// move. Deleting a separator deletes nothing, so step past it and take
|
|
238
|
+
// the digit instead — otherwise Backspace on "1,234" appears to do
|
|
239
|
+
// nothing at all.
|
|
240
|
+
let digitsLeft = digitsBefore(typed, caret);
|
|
241
|
+
const deletedSeparator =
|
|
242
|
+
typed.length < display.length && digitsLeft === digitsBefore(display, caret + 1);
|
|
243
|
+
if (deletedSeparator && digitsLeft > 0) digitsLeft -= 1;
|
|
244
|
+
|
|
245
|
+
const nextDisplay = formatMinor(next, currency, locale);
|
|
246
|
+
pendingCaretRef.current = offsetAfterDigits(nextDisplay, digitsLeft);
|
|
247
|
+
|
|
248
|
+
commit(next);
|
|
249
|
+
},
|
|
250
|
+
[commit, currency, display, locale, max],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<InputGroup className={groupClassName} data-disabled={disabled ? '' : undefined}>
|
|
255
|
+
{showSymbol ? (
|
|
256
|
+
// aria-hidden: the accessible name belongs on the field's label.
|
|
257
|
+
// A screen reader announcing "dollar sign" on entry is noise; a
|
|
258
|
+
// label reading "Price in US dollars" is what actually helps.
|
|
259
|
+
<InputGroupAddon align="inline-start" aria-hidden="true">
|
|
260
|
+
{symbol}
|
|
261
|
+
</InputGroupAddon>
|
|
262
|
+
) : null}
|
|
263
|
+
<InputGroupInput
|
|
264
|
+
ref={inputRef}
|
|
265
|
+
// `inputMode` rather than `type="number"`: a number input rejects
|
|
266
|
+
// the grouped string this component displays, and its spinner is
|
|
267
|
+
// meaningless for an amount. This still opens a numeric keypad.
|
|
268
|
+
inputMode="decimal"
|
|
269
|
+
autoComplete="off"
|
|
270
|
+
value={display}
|
|
271
|
+
onChange={handleChange}
|
|
272
|
+
onBlur={onBlur}
|
|
273
|
+
disabled={disabled}
|
|
274
|
+
// Left-aligned, hugging the symbol. `text-right` looks correct in a
|
|
275
|
+
// column of amounts and wrong in a single field: `flex-1` stretches
|
|
276
|
+
// the control to the group's full width, so the symbol sits at one
|
|
277
|
+
// edge and the number at the other with a gap between them. Right
|
|
278
|
+
// alignment belongs to a table cell, which can set it via `className`.
|
|
279
|
+
className={cn('tabular-nums', className)}
|
|
280
|
+
{...props}
|
|
281
|
+
/>
|
|
282
|
+
</InputGroup>
|
|
283
|
+
);
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
MoneyField.displayName = 'MoneyField';
|
|
287
|
+
|
|
288
|
+
export { MoneyField };
|
|
@@ -30,7 +30,7 @@ function cleanInput(
|
|
|
30
30
|
if (!input) return ''
|
|
31
31
|
|
|
32
32
|
// Remove all whitespace and convert to uppercase for consistency
|
|
33
|
-
|
|
33
|
+
const cleaned = input.replace(/\s+/g, '').trim()
|
|
34
34
|
|
|
35
35
|
if (validationMode === 'custom' && customValidator) {
|
|
36
36
|
// For custom validation, filter character by character
|