@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.
- 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/index.tsx +33 -6
- package/src/components/forms/input-group/index.tsx +32 -17
- 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/textarea/index.tsx +10 -4
- 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
|
@@ -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
|
|
@@ -56,8 +56,7 @@ export interface TagsInputRootProps
|
|
|
56
56
|
children?: React.ReactNode | ((context: { value: TagValue[] }) => React.ReactNode);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
export
|
|
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
|
|
70
|
-
extends React.ComponentPropsWithoutRef<"span"> {}
|
|
68
|
+
export type TagsInputItemTextProps = React.ComponentPropsWithoutRef<"span">;
|
|
71
69
|
|
|
72
|
-
export
|
|
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
|
-
|
|
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 =
|
|
547
|
+
const isAdded = onItemAdd(value);
|
|
541
548
|
if (isAdded) {
|
|
542
549
|
event.currentTarget.value = "";
|
|
543
|
-
|
|
550
|
+
setHighlightedIndex(null);
|
|
544
551
|
}
|
|
545
552
|
event.preventDefault();
|
|
546
553
|
},
|
|
547
|
-
[
|
|
554
|
+
[onItemAdd, setHighlightedIndex]
|
|
548
555
|
);
|
|
549
556
|
|
|
550
557
|
const onTab = React.useCallback(
|
|
551
558
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
552
|
-
if (!
|
|
559
|
+
if (!addOnTab) return;
|
|
553
560
|
onCustomKeydown(event);
|
|
554
561
|
},
|
|
555
|
-
[
|
|
562
|
+
[addOnTab, onCustomKeydown]
|
|
556
563
|
);
|
|
557
564
|
|
|
558
565
|
React.useEffect(() => {
|
|
559
566
|
if (!autoFocus) return;
|
|
560
|
-
const id = requestAnimationFrame(() =>
|
|
567
|
+
const id = requestAnimationFrame(() => inputRef.current?.focus());
|
|
561
568
|
return () => cancelAnimationFrame(id);
|
|
562
|
-
}, [autoFocus,
|
|
569
|
+
}, [autoFocus, inputRef]);
|
|
563
570
|
|
|
564
571
|
const composedRef = React.useCallback(
|
|
565
572
|
(node: HTMLInputElement | null) => {
|
|
566
|
-
|
|
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,
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
}, [
|
|
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?.(
|
|
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?.(
|
|
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(
|
|
759
|
-
|
|
760
|
-
}, [
|
|
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 =
|
|
779
|
-
|
|
792
|
+
const index = contextValue.indexOf(itemValue);
|
|
793
|
+
onItemUpdate(index, editValue);
|
|
780
794
|
} else if (event.key === "Escape") {
|
|
781
|
-
setEditValue(
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
795
|
+
setEditValue(displayValue);
|
|
796
|
+
setEditingIndex(null);
|
|
797
|
+
setHighlightedIndex(itemIndex);
|
|
798
|
+
inputRef.current?.focus();
|
|
785
799
|
}
|
|
786
800
|
event.stopPropagation();
|
|
787
801
|
},
|
|
788
802
|
[
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
803
|
+
contextValue,
|
|
804
|
+
onItemUpdate,
|
|
805
|
+
setEditingIndex,
|
|
806
|
+
displayValue,
|
|
793
807
|
editValue,
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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={
|
|
187
|
+
ref={composedRef}
|
|
184
188
|
variant={variant}
|
|
185
189
|
disabled={disabled}
|
|
186
190
|
className={cn(
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}%` }
|