@djangocfg/ui-core 2.1.540 → 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.
Files changed (50) hide show
  1. package/README.md +3 -1
  2. package/package.json +12 -9
  3. package/src/components/data/BalancedText/hooks/useMaxLinesWidth.ts +4 -25
  4. package/src/components/forms/button-download/index.tsx +1 -1
  5. package/src/components/forms/datetime-field/date-time-field.tsx +1 -1
  6. package/src/components/forms/editable/index.tsx +7 -3
  7. package/src/components/forms/input/index.tsx +33 -6
  8. package/src/components/forms/input-group/index.tsx +32 -17
  9. package/src/components/forms/mask-input/index.tsx +7 -3
  10. package/src/components/forms/money-field/README.md +79 -0
  11. package/src/components/forms/money-field/index.tsx +288 -0
  12. package/src/components/forms/otp/use-otp-input.ts +1 -1
  13. package/src/components/forms/tags-input/index.tsx +55 -41
  14. package/src/components/forms/textarea/index.tsx +10 -4
  15. package/src/components/forms/time-picker/index.tsx +7 -3
  16. package/src/components/index.ts +4 -0
  17. package/src/components/layout/key-value/index.tsx +9 -7
  18. package/src/components/layout/resizable/index.tsx +6 -1
  19. package/src/components/navigation/command/index.tsx +24 -6
  20. package/src/components/navigation/link/LinkContext.tsx +3 -1
  21. package/src/components/navigation/pagination/pagination-static.tsx +1 -1
  22. package/src/components/navigation/tabs/index.tsx +30 -9
  23. package/src/components/overlay/responsive-sheet/index.tsx +4 -4
  24. package/src/components/select/helpers.tsx +1 -1
  25. package/src/components/select/multi-select-pro-async.tsx +13 -6
  26. package/src/components/select/multi-select-pro.tsx +3 -4
  27. package/src/components/specialized/flag/Flag.tsx +11 -5
  28. package/src/components/specialized/flag/flag-map.ts +13 -6
  29. package/src/components/specialized/image-with-fallback/index.tsx +9 -4
  30. package/src/components/specialized/presence/index.tsx +2 -3
  31. package/src/components/specialized/token-icon/index.tsx +26 -13
  32. package/src/hooks/audio/useAudioPrefs.ts +8 -3
  33. package/src/hooks/device/useBrowserDetect.ts +5 -1
  34. package/src/hooks/dom/useImageLoader.ts +24 -20
  35. package/src/hooks/dom/useScroll.ts +7 -6
  36. package/src/hooks/events/useEventsBus.ts +19 -5
  37. package/src/hooks/hotkey/useHotkeyChord.ts +11 -4
  38. package/src/hooks/hotkey/useHotkeyHelp.ts +8 -3
  39. package/src/hooks/router/adapter.tsx +3 -1
  40. package/src/hooks/state/storage-quota.ts +27 -0
  41. package/src/hooks/state/useDebouncedCallback.ts +26 -20
  42. package/src/hooks/state/useLocalStorage.ts +7 -13
  43. package/src/hooks/state/useSessionStorage.ts +7 -9
  44. package/src/lib/compose-event-handlers.ts +5 -5
  45. package/src/lib/dialog-service/getDialog.ts +1 -1
  46. package/src/lib/get-element-ref.ts +9 -6
  47. package/src/lib/pretext/pretext.types.ts +25 -70
  48. package/src/lib/pretext/use-pretext.ts +8 -12
  49. package/src/snippets/LazyComponent.tsx +9 -9
  50. 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.540",
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.540",
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.2.4",
138
+ "react": "^19.0.0",
137
139
  "react-device-detect": "^2.2.3",
138
- "react-dom": "^19.2.4",
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/i18n": "^2.1.540",
210
- "@djangocfg/typescript-config": "^2.1.540",
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
- // We replicate the structure of `useBalancedWidth` so that pretext stays an
15
- // optionalDependency: import the runtime lazily via require, mirror the
16
- // minimal `walkLineRanges` surface.
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;
@@ -63,7 +63,7 @@ export interface DownloadButtonProps extends Omit<ButtonProps, 'onClick'> {
63
63
  /**
64
64
  * Optional request body for POST requests
65
65
  */
66
- body?: any
66
+ body?: unknown
67
67
  }
68
68
 
69
69
  type DownloadStatus = 'idle' | 'downloading' | 'success' | 'error'
@@ -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="start"
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 interface EditablePreviewProps extends React.ComponentPropsWithoutRef<"span"> {}
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 interface EditableTextareaProps extends Omit<React.ComponentPropsWithoutRef<"textarea">, "value" | "defaultValue"> {}
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
- const startEditing = React.useCallback(() => {
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);
@@ -35,6 +35,18 @@ export interface InputProps extends React.ComponentProps<"input"> {
35
35
  * - `sm` — 36px, smaller text (inline edits, dense rows, settings chips).
36
36
  */
37
37
  inputSize?: 'default' | 'sm';
38
+ /**
39
+ * Drop the field chrome — border, surface, rounding, focus ring — for an
40
+ * input that sits inside a control that draws them itself (`InputGroup`).
41
+ *
42
+ * A wrapper cannot do this from the outside: `focus-visible:ring-0` and
43
+ * `focus-visible:ring-1` are the same specificity, so which one wins is
44
+ * decided by their order in the generated stylesheet, not by the order they
45
+ * are passed to `cn()`. Suppression like that works until a Tailwind version
46
+ * reorders its output. Omitting the rules is the only stable way to not have
47
+ * them.
48
+ */
49
+ bare?: boolean;
38
50
  }
39
51
 
40
52
  /**
@@ -49,9 +61,19 @@ export interface InputProps extends React.ComponentProps<"input"> {
49
61
  * and a tight 1px solid ring of the same colour doubles it to a clean ~2px
50
62
  * edge (NO blurry translucent halo). `:focus-visible` only, so mouse users on
51
63
  * other controls don't get rings. This is the sharp Vercel/Linear look.
64
+ *
65
+ * Split in two so an input inside `InputGroup` can omit the chrome rather than
66
+ * override it — see `bare`.
52
67
  */
53
- const INPUT_BASE =
54
- "flex w-full rounded-[var(--radius)] border border-border bg-input shadow-sm transition-[color,background-color,border-color,box-shadow] file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
68
+ /** Everything an input needs regardless of who draws its edge. */
69
+ const INPUT_CORE =
70
+ "flex w-full transition-[color,background-color,border-color,box-shadow] file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50";
71
+
72
+ /** The field's own edge: border, surface, rounding, and the focus treatment. */
73
+ const INPUT_CHROME =
74
+ "rounded-[var(--radius)] border border-border bg-input shadow-sm focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring";
75
+
76
+ const INPUT_BASE = cn(INPUT_CORE, INPUT_CHROME);
55
77
 
56
78
  const INPUT_SIZE: Record<NonNullable<InputProps['inputSize']>, string> = {
57
79
  default: "h-10 px-3 py-2 text-base md:text-sm",
@@ -59,8 +81,10 @@ const INPUT_SIZE: Record<NonNullable<InputProps['inputSize']>, string> = {
59
81
  };
60
82
 
61
83
  /** Resolve the full input class for a given size. */
62
- export const inputClass = (size: NonNullable<InputProps['inputSize']> = 'default') =>
63
- cn(INPUT_BASE, INPUT_SIZE[size]);
84
+ export const inputClass = (
85
+ size: NonNullable<InputProps['inputSize']> = 'default',
86
+ bare = false,
87
+ ) => cn(bare ? INPUT_CORE : INPUT_BASE, INPUT_SIZE[size], bare && 'h-full px-0');
64
88
 
65
89
  /** Default-size input styling — kept as a named export for reuse (e.g. Editable). */
66
90
  export const INPUT_CLASS = inputClass('default');
@@ -71,9 +95,12 @@ export const INPUT_CLASS = inputClass('default');
71
95
  */
72
96
  export const TEXTAREA_CLASS = cn(INPUT_BASE, 'min-h-[60px] px-3 py-2 text-sm');
73
97
 
98
+ /** `TEXTAREA_CLASS` without the field's own edge — for use inside `InputGroup`. */
99
+ export const TEXTAREA_BARE_CLASS = cn(INPUT_CORE, 'min-h-[60px] py-3 text-sm');
100
+
74
101
  const Input = React.forwardRef<HTMLInputElement, InputProps>(
75
- ({ className, type, storageKey, storageType, storageTtl, inputSize = 'default', onChange, defaultValue, value, ...props }, ref) => {
76
- const resolvedClass = cn(inputClass(inputSize), className);
102
+ ({ className, type, storageKey, storageType, storageTtl, inputSize = 'default', bare = false, onChange, defaultValue, value, ...props }, ref) => {
103
+ const resolvedClass = cn(inputClass(inputSize, bare), className);
77
104
  const storageOptions: UseStoredValueOptions | undefined =
78
105
  storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
79
106
 
@@ -14,7 +14,12 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
14
14
  data-slot="input-group"
15
15
  role="group"
16
16
  className={cn(
17
- "group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-[var(--radius)] border outline-none transition-[color,box-shadow]",
17
+ // `border-border` and `bg-input`, the same pair `Input` resolves to. The
18
+ // group had `border-input`, which in the light theme is the *fill*
19
+ // token (97% L) rather than the edge one (86%) — so its border was
20
+ // nearly invisible next to a plain field's, and a focused money input
21
+ // showed a crisp inner rectangle inside a ghost of an outer one.
22
+ "group/input-group border-border bg-input shadow-xs relative flex w-full items-center rounded-[var(--radius)] border outline-none transition-[color,background-color,border-color,box-shadow]",
18
23
  // Matches `Input`'s default size (h-10), not its `sm` one.
19
24
  //
20
25
  // This was `h-9`, which made a grouped input a row shorter than a bare
@@ -29,13 +34,28 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
29
34
  "h-10 has-[>textarea]:h-auto",
30
35
 
31
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}`.
32
47
  "has-[>[data-align=inline-start]]:[&>input]:pl-2",
48
+ "not-has-[>[data-align=inline-start]]:[&>input]:pl-3",
33
49
  "has-[>[data-align=inline-end]]:[&>input]:pr-2",
50
+ "not-has-[>[data-align=inline-end]]:[&>input]:pr-3",
34
51
  "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
35
52
  "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
36
53
 
37
- // Focus state.
38
- "has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
54
+ // Focus state. Border AND ring, the same pair `Input` uses on its own
55
+ // — the group is standing in for a field's edge, so it has to draw the
56
+ // whole treatment. Ring alone left the group's border its resting grey
57
+ // while the control inside lit up, which reads as two nested fields.
58
+ "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
39
59
 
40
60
  // Error state.
41
61
  "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
@@ -154,17 +174,14 @@ const InputGroupInput = React.forwardRef<
154
174
  return (
155
175
  <Input
156
176
  ref={ref}
177
+ bare
157
178
  data-slot="input-group-control"
158
- className={cn(
159
- // `focus-visible:border-transparent` alongside `ring-0`: the group
160
- // draws the focus ring for the whole control, and `INPUT_BASE` also
161
- // turns the input's own border to `--ring` on focus. Suppressing only
162
- // the ring left that border visible, so a focused money field showed a
163
- // rectangle inside a rectangle — the addon sitting outside the inner
164
- // one, which is exactly what the group exists to prevent.
165
- "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:border-transparent focus-visible:ring-0 dark:bg-transparent",
166
- className
167
- )}
179
+ // `bare` omits the field chrome instead of overriding it. Cancelling it
180
+ // from here needed `focus-visible:ring-0` to beat `focus-visible:ring-1`
181
+ // at equal specificity — decided by stylesheet order, not by `cn()` — so
182
+ // a focused money field drew a rectangle inside a rectangle whenever the
183
+ // order went the other way.
184
+ className={cn("flex-1", className)}
168
185
  {...props}
169
186
  />
170
187
  )
@@ -177,11 +194,9 @@ function InputGroupTextarea({
177
194
  }: React.ComponentProps<"textarea">) {
178
195
  return (
179
196
  <Textarea
197
+ bare
180
198
  data-slot="input-group-control"
181
- className={cn(
182
- "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
183
- className
184
- )}
199
+ className={cn("flex-1 resize-none", className)}
185
200
  {...props}
186
201
  />
187
202
  )
@@ -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
- const end = input.selectionEnd ?? 0;
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, maskChar, onKeyDown, updateValue]
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, raw } = applyMask(rawValue, maskParts, maskChar);
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.