@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
@@ -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
- let cleaned = input.replace(/\s+/g, '').trim()
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
@@ -56,8 +56,7 @@ export interface TagsInputRootProps
56
56
  children?: React.ReactNode | ((context: { value: TagValue[] }) => React.ReactNode);
57
57
  }
58
58
 
59
- export interface TagsInputInputProps
60
- extends Omit<React.ComponentPropsWithoutRef<"input">, "value" | "defaultValue"> {}
59
+ export type TagsInputInputProps = Omit<React.ComponentPropsWithoutRef<"input">, "value" | "defaultValue">;
61
60
 
62
61
  export interface TagsInputItemProps extends React.ComponentPropsWithoutRef<"div"> {
63
62
  /** The value of the item. */
@@ -66,11 +65,9 @@ export interface TagsInputItemProps extends React.ComponentPropsWithoutRef<"div"
66
65
  disabled?: boolean;
67
66
  }
68
67
 
69
- export interface TagsInputItemTextProps
70
- extends React.ComponentPropsWithoutRef<"span"> {}
68
+ export type TagsInputItemTextProps = React.ComponentPropsWithoutRef<"span">;
71
69
 
72
- export interface TagsInputItemDeleteProps
73
- extends React.ComponentPropsWithoutRef<"button"> {}
70
+ export type TagsInputItemDeleteProps = React.ComponentPropsWithoutRef<"button">;
74
71
 
75
72
  // =============================================================================
76
73
  // Context
@@ -169,7 +166,16 @@ const TagsInput = React.forwardRef<HTMLDivElement, TagsInputRootProps>(
169
166
  } = props;
170
167
 
171
168
  const [value = [], setValue] = React.useState<TagValue[] | undefined>(defaultValue);
172
- const resolvedValue = valueProp !== undefined ? valueProp : (value ?? []);
169
+ /*
170
+ * Memoised because the `?? []` branch mints a NEW array each render, and
171
+ * four `useCallback`s below take `resolvedValue` as a dependency — so an
172
+ * uncontrolled, empty TagsInput rebuilt all four on every render and the
173
+ * memoisation bought nothing. Identity is what those hooks compare.
174
+ */
175
+ const resolvedValue = React.useMemo(
176
+ () => (valueProp !== undefined ? valueProp : (value ?? [])),
177
+ [valueProp, value],
178
+ );
173
179
 
174
180
  const [highlightedIndex, setHighlightedIndex] = React.useState<number | null>(null);
175
181
  const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
@@ -531,46 +537,47 @@ const TagsInputInput = React.forwardRef<HTMLInputElement, TagsInputInputProps>(
531
537
  (props, ref) => {
532
538
  const { autoFocus, ...inputProps } = props;
533
539
  const context = useTagsInput("TagsInputInput");
540
+ const { onItemAdd, setHighlightedIndex, addOnTab, inputRef } = context;
534
541
 
535
542
  const onCustomKeydown = React.useCallback(
536
543
  (event: React.KeyboardEvent<HTMLInputElement>) => {
537
544
  if (event.defaultPrevented) return;
538
545
  const value = event.currentTarget.value;
539
546
  if (!value) return;
540
- const isAdded = context.onItemAdd(value);
547
+ const isAdded = onItemAdd(value);
541
548
  if (isAdded) {
542
549
  event.currentTarget.value = "";
543
- context.setHighlightedIndex(null);
550
+ setHighlightedIndex(null);
544
551
  }
545
552
  event.preventDefault();
546
553
  },
547
- [context.onItemAdd, context.setHighlightedIndex]
554
+ [onItemAdd, setHighlightedIndex]
548
555
  );
549
556
 
550
557
  const onTab = React.useCallback(
551
558
  (event: React.KeyboardEvent<HTMLInputElement>) => {
552
- if (!context.addOnTab) return;
559
+ if (!addOnTab) return;
553
560
  onCustomKeydown(event);
554
561
  },
555
- [context.addOnTab, onCustomKeydown]
562
+ [addOnTab, onCustomKeydown]
556
563
  );
557
564
 
558
565
  React.useEffect(() => {
559
566
  if (!autoFocus) return;
560
- const id = requestAnimationFrame(() => context.inputRef.current?.focus());
567
+ const id = requestAnimationFrame(() => inputRef.current?.focus());
561
568
  return () => cancelAnimationFrame(id);
562
- }, [autoFocus, context.inputRef]);
569
+ }, [autoFocus, inputRef]);
563
570
 
564
571
  const composedRef = React.useCallback(
565
572
  (node: HTMLInputElement | null) => {
566
- context.inputRef.current = node;
573
+ inputRef.current = node;
567
574
  if (typeof ref === "function") {
568
575
  ref(node);
569
576
  } else if (ref) {
570
577
  (ref as React.MutableRefObject<HTMLInputElement | null>).current = node;
571
578
  }
572
579
  },
573
- [ref, context.inputRef]
580
+ [ref, inputRef]
574
581
  );
575
582
 
576
583
  return (
@@ -664,10 +671,11 @@ const TagsInputItem = React.forwardRef<HTMLDivElement, TagsInputItemProps>(
664
671
  const itemDisabled = itemDisabledProp || context.disabled;
665
672
  const displayValue = context.displayValue(value);
666
673
 
674
+ const { setHighlightedIndex, inputRef } = context;
667
675
  const onItemSelect = React.useCallback(() => {
668
- context.setHighlightedIndex(index);
669
- context.inputRef.current?.focus();
670
- }, [context.setHighlightedIndex, context.inputRef, index]);
676
+ setHighlightedIndex(index);
677
+ inputRef.current?.focus();
678
+ }, [setHighlightedIndex, inputRef, index]);
671
679
 
672
680
  return (
673
681
  <TagsInputItemContext.Provider
@@ -694,6 +702,10 @@ const TagsInputItem = React.forwardRef<HTMLDivElement, TagsInputItemProps>(
694
702
  data-editing={isEditing ? "" : undefined}
695
703
  data-editable={context.editable ? "" : undefined}
696
704
  data-disabled={itemDisabled ? "" : undefined}
705
+ {...itemProps}
706
+ // Spread stays ABOVE className and the handlers: below them, a consumer
707
+ // that passes any of these props replaces the composed behaviour
708
+ // outright — a tag with an onClick stops being selectable.
697
709
  className={cn(
698
710
  "inline-flex items-center gap-1 rounded-[var(--radius)] border bg-secondary px-2 py-0.5 text-sm text-secondary-foreground transition-colors",
699
711
  isHighlighted && "ring-1 ring-ring",
@@ -707,14 +719,14 @@ const TagsInputItem = React.forwardRef<HTMLDivElement, TagsInputItemProps>(
707
719
  onItemSelect();
708
720
  }
709
721
  }}
710
- onDoubleClick={() => {
711
- itemProps.onDoubleClick?.(undefined as unknown as React.MouseEvent<HTMLDivElement>);
722
+ onDoubleClick={(event) => {
723
+ itemProps.onDoubleClick?.(event);
712
724
  if (context.editable && !itemDisabled) {
713
725
  requestAnimationFrame(() => context.setEditingIndex(index));
714
726
  }
715
727
  }}
716
- onPointerUp={() => {
717
- itemProps.onPointerUp?.(undefined as unknown as React.PointerEvent<HTMLDivElement>);
728
+ onPointerUp={(event) => {
729
+ itemProps.onPointerUp?.(event);
718
730
  if (pointerTypeRef.current === "mouse") onItemSelect();
719
731
  }}
720
732
  onPointerDown={(event) => {
@@ -736,7 +748,6 @@ const TagsInputItem = React.forwardRef<HTMLDivElement, TagsInputItemProps>(
736
748
  context.onItemLeave();
737
749
  }
738
750
  }}
739
- {...itemProps}
740
751
  />
741
752
  </TagsInputItemContext.Provider>
742
753
  );
@@ -754,10 +765,13 @@ function TagsInputEditableItemText() {
754
765
  const itemContext = useTagsInputItem("TagsInputEditableItemText");
755
766
  const [editValue, setEditValue] = React.useState(itemContext.displayValue);
756
767
 
768
+ const { setEditingIndex, setHighlightedIndex, onItemUpdate, inputRef, value: contextValue } = context;
769
+ const { displayValue, value: itemValue, index: itemIndex } = itemContext;
770
+
757
771
  const onBlur = React.useCallback(() => {
758
- setEditValue(itemContext.displayValue);
759
- context.setEditingIndex(null);
760
- }, [context.setEditingIndex, itemContext.displayValue]);
772
+ setEditValue(displayValue);
773
+ setEditingIndex(null);
774
+ }, [setEditingIndex, displayValue]);
761
775
 
762
776
  const onChange = React.useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
763
777
  const target = event.target;
@@ -775,26 +789,26 @@ function TagsInputEditableItemText() {
775
789
  const onKeyDown = React.useCallback(
776
790
  (event: React.KeyboardEvent<HTMLInputElement>) => {
777
791
  if (event.key === "Enter") {
778
- const index = context.value.indexOf(itemContext.value);
779
- context.onItemUpdate(index, editValue);
792
+ const index = contextValue.indexOf(itemValue);
793
+ onItemUpdate(index, editValue);
780
794
  } else if (event.key === "Escape") {
781
- setEditValue(itemContext.displayValue);
782
- context.setEditingIndex(null);
783
- context.setHighlightedIndex(itemContext.index);
784
- context.inputRef.current?.focus();
795
+ setEditValue(displayValue);
796
+ setEditingIndex(null);
797
+ setHighlightedIndex(itemIndex);
798
+ inputRef.current?.focus();
785
799
  }
786
800
  event.stopPropagation();
787
801
  },
788
802
  [
789
- context.value,
790
- context.onItemUpdate,
791
- context.setEditingIndex,
792
- itemContext.displayValue,
803
+ contextValue,
804
+ onItemUpdate,
805
+ setEditingIndex,
806
+ displayValue,
793
807
  editValue,
794
- itemContext.value,
795
- context.setHighlightedIndex,
796
- itemContext.index,
797
- context.inputRef,
808
+ itemValue,
809
+ setHighlightedIndex,
810
+ itemIndex,
811
+ inputRef,
798
812
  ]
799
813
  );
800
814
 
@@ -2,7 +2,7 @@ import * as React from 'react';
2
2
 
3
3
  import { useStoredValue, type StorageType, type UseStoredValueOptions } from '../../../hooks';
4
4
  import { cn } from '../../../lib/utils';
5
- import { TEXTAREA_CLASS } from '../input';
5
+ import { TEXTAREA_BARE_CLASS, TEXTAREA_CLASS } from '../input';
6
6
 
7
7
  export interface TextareaProps extends React.ComponentProps<"textarea"> {
8
8
  /**
@@ -15,10 +15,16 @@ export interface TextareaProps extends React.ComponentProps<"textarea"> {
15
15
  storageType?: StorageType;
16
16
  /** TTL in ms */
17
17
  storageTtl?: number;
18
+ /**
19
+ * Drop the field chrome for a textarea inside a control that draws its own
20
+ * (`InputGroup`). See `Input`'s `bare` for why this is a variant rather than
21
+ * something a wrapper overrides.
22
+ */
23
+ bare?: boolean;
18
24
  }
19
25
 
20
26
  const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
21
- ({ className, storageKey, storageType, storageTtl, onChange, defaultValue, value, ...props }, ref) => {
27
+ ({ className, storageKey, storageType, storageTtl, bare = false, onChange, defaultValue, value, ...props }, ref) => {
22
28
  const storageOptions: UseStoredValueOptions | undefined =
23
29
  storageKey ? { storage: storageType ?? 'local', ttl: storageTtl } : undefined;
24
30
 
@@ -41,7 +47,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
41
47
  if (value !== undefined) {
42
48
  return (
43
49
  <textarea
44
- className={cn(TEXTAREA_CLASS, className)}
50
+ className={cn(bare ? TEXTAREA_BARE_CLASS : TEXTAREA_CLASS, className)}
45
51
  ref={ref}
46
52
  value={value}
47
53
  onChange={storageKey ? handleChange : onChange}
@@ -52,7 +58,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
52
58
 
53
59
  return (
54
60
  <textarea
55
- className={cn(TEXTAREA_CLASS, className)}
61
+ className={cn(bare ? TEXTAREA_BARE_CLASS : TEXTAREA_CLASS, className)}
56
62
  ref={ref}
57
63
  defaultValue={storageKey && storedValue ? storedValue : defaultValue}
58
64
  onChange={storageKey ? handleChange : onChange}
@@ -3,6 +3,7 @@
3
3
  import { Clock } from "lucide-react";
4
4
  import * as React from "react";
5
5
 
6
+ import { useComposedRefs } from "../../../lib/compose-refs";
6
7
  import { cn } from "../../../lib/utils";
7
8
  import { Button } from "../../forms/button";
8
9
  import { Popover, PopoverContent, PopoverTrigger } from "../../overlay/popover";
@@ -166,8 +167,11 @@ const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
166
167
  }, [minuteStep]);
167
168
 
168
169
  const isFormControl = React.useRef(false);
169
- const rootRef = React.useRef<HTMLDivElement>(null);
170
- React.useImperativeHandle(ref, () => rootRef.current as unknown as HTMLButtonElement);
170
+ // The trigger is a <button>, and the caller's ref must see `null` before it
171
+ // mounts. useImperativeHandle cannot express that it must produce a
172
+ // handle — so the two refs are composed onto the element instead.
173
+ const rootRef = React.useRef<HTMLButtonElement | null>(null);
174
+ const composedRef = useComposedRefs(rootRef, ref);
171
175
 
172
176
  React.useEffect(() => {
173
177
  if (rootRef.current) {
@@ -180,7 +184,7 @@ const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>(
180
184
  <Popover open={open} onOpenChange={setOpen}>
181
185
  <PopoverTrigger asChild>
182
186
  <Button
183
- ref={rootRef as unknown as React.Ref<HTMLButtonElement>}
187
+ ref={composedRef}
184
188
  variant={variant}
185
189
  disabled={disabled}
186
190
  className={cn(
@@ -31,6 +31,10 @@ export type { DownloadButtonProps } from './forms/button-download';
31
31
  export { PopoverActionButton } from './forms/popover-action-button';
32
32
  export type { PopoverActionButtonProps } from './forms/popover-action-button';
33
33
 
34
+ // Money Field — amount in, minor units out.
35
+ export { MoneyField, minorUnitFactor, currencyFractionDigits } from './forms/money-field';
36
+ export type { MoneyFieldProps } from './forms/money-field';
37
+
34
38
  // Mask Input
35
39
  export { MaskInput } from './forms/mask-input';
36
40
  export type { MaskInputProps, MaskDefinition } from './forms/mask-input';
@@ -408,7 +408,10 @@ function KeyValueKeyInput(props: KeyValueKeyInputProps) {
408
408
  const {
409
409
  onChange: onChangeProp,
410
410
  onPaste: onPasteProp,
411
- asChild,
411
+ // Declared on the props type but not honoured here: unlike the `div`-based
412
+ // parts of this component, this one renders a concrete <Input>, so there is
413
+ // no element to Slot into. Destructured only to keep it off the DOM.
414
+ asChild: _asChild,
412
415
  disabled,
413
416
  readOnly,
414
417
  required,
@@ -565,10 +568,7 @@ function KeyValueKeyInput(props: KeyValueKeyInputProps) {
565
568
  store.setState("value", newValue)
566
569
 
567
570
  if (context.onPaste) {
568
- context.onPaste(
569
- event.nativeEvent as unknown as ClipboardEvent,
570
- parsed,
571
- )
571
+ context.onPaste(event.nativeEvent, parsed)
572
572
  }
573
573
  }
574
574
  }
@@ -608,7 +608,9 @@ interface KeyValueValueInputProps
608
608
  function KeyValueValueInput(props: KeyValueValueInputProps) {
609
609
  const {
610
610
  onChange: onChangeProp,
611
- asChild,
611
+ // Same as KeyValueKeyInput: renders a concrete <Textarea>, nothing to Slot
612
+ // into. Destructured only to keep it off the DOM.
613
+ asChild: _asChild,
612
614
  disabled,
613
615
  readOnly,
614
616
  required,
@@ -724,7 +726,7 @@ function KeyValueValueInput(props: KeyValueValueInputProps) {
724
726
  )
725
727
  }
726
728
 
727
- interface KeyValueRemoveProps extends React.ComponentProps<typeof Button> {}
729
+ type KeyValueRemoveProps = React.ComponentProps<typeof Button>;
728
730
 
729
731
  function KeyValueRemove(props: KeyValueRemoveProps) {
730
732
  const { onClick: onClickProp, children, ...removeProps } = props
@@ -66,7 +66,12 @@ const ResizablePanel = React.forwardRef<
66
66
 
67
67
  // SSR fallback - render static div with default size
68
68
  if (!mounted) {
69
- const direction = (props as any)['data-panel-group-direction']
69
+ // `data-*` attributes are not part of the typed prop surface, so the read
70
+ // needs a cast. Narrowed to the one attribute rather than `any` on `props`:
71
+ // this opens exactly the key it uses and leaves the rest checked.
72
+ const direction = (props as { 'data-panel-group-direction'?: string })[
73
+ 'data-panel-group-direction'
74
+ ]
70
75
  const sizeStyle = defaultSize
71
76
  ? direction === 'vertical'
72
77
  ? { height: `${defaultSize}%` }