@cdx-ui/components 0.0.1-beta.75 → 0.0.1-beta.77
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/lib/commonjs/components/BottomSheet/index.js +68 -25
- package/lib/commonjs/components/BottomSheet/index.js.map +1 -1
- package/lib/commonjs/components/BottomSheet/styles.js +5 -42
- package/lib/commonjs/components/BottomSheet/styles.js.map +1 -1
- package/lib/commonjs/components/CurrencyInput/index.js +157 -0
- package/lib/commonjs/components/CurrencyInput/index.js.map +1 -0
- package/lib/commonjs/components/CurrencyInput/styles.js +31 -0
- package/lib/commonjs/components/CurrencyInput/styles.js.map +1 -0
- package/lib/commonjs/components/Link/styles.js +1 -1
- package/lib/commonjs/components/Select/styles.js +1 -1
- package/lib/commonjs/components/Select/styles.js.map +1 -1
- package/lib/commonjs/components/index.js +12 -0
- package/lib/commonjs/components/index.js.map +1 -1
- package/lib/commonjs/metro/withForgeMetroConfig.js +50 -1
- package/lib/commonjs/metro/withForgeMetroConfig.js.map +1 -1
- package/lib/commonjs/styles.css +0 -5
- package/lib/module/components/BottomSheet/index.js +70 -27
- package/lib/module/components/BottomSheet/index.js.map +1 -1
- package/lib/module/components/BottomSheet/styles.js +4 -42
- package/lib/module/components/BottomSheet/styles.js.map +1 -1
- package/lib/module/components/CurrencyInput/index.js +154 -0
- package/lib/module/components/CurrencyInput/index.js.map +1 -0
- package/lib/module/components/CurrencyInput/styles.js +27 -0
- package/lib/module/components/CurrencyInput/styles.js.map +1 -0
- package/lib/module/components/Link/styles.js +1 -1
- package/lib/module/components/Select/styles.js +1 -1
- package/lib/module/components/Select/styles.js.map +1 -1
- package/lib/module/components/index.js +1 -0
- package/lib/module/components/index.js.map +1 -1
- package/lib/module/metro/withForgeMetroConfig.js +49 -1
- package/lib/module/metro/withForgeMetroConfig.js.map +1 -1
- package/lib/module/styles.css +0 -5
- package/lib/typescript/components/BottomSheet/index.d.ts.map +1 -1
- package/lib/typescript/components/BottomSheet/styles.d.ts +0 -5
- package/lib/typescript/components/BottomSheet/styles.d.ts.map +1 -1
- package/lib/typescript/components/CurrencyInput/index.d.ts +15 -0
- package/lib/typescript/components/CurrencyInput/index.d.ts.map +1 -0
- package/lib/typescript/components/CurrencyInput/styles.d.ts +9 -0
- package/lib/typescript/components/CurrencyInput/styles.d.ts.map +1 -0
- package/lib/typescript/components/Select/styles.d.ts.map +1 -1
- package/lib/typescript/components/index.d.ts +1 -0
- package/lib/typescript/components/index.d.ts.map +1 -1
- package/lib/typescript/metro/withForgeMetroConfig.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/components/BottomSheet/index.tsx +99 -42
- package/src/components/BottomSheet/styles.ts +7 -47
- package/src/components/CurrencyInput/index.tsx +192 -0
- package/src/components/CurrencyInput/styles.ts +44 -0
- package/src/components/Link/styles.ts +1 -1
- package/src/components/Select/styles.ts +0 -2
- package/src/components/index.ts +1 -0
- package/src/metro/withForgeMetroConfig.ts +54 -1
- package/src/styles.css +0 -5
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { forwardRef, useState } from 'react';
|
|
2
|
+
import { type TextInput, type TextInputProps, type ViewProps } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
InputField as InputFieldPrimitive,
|
|
5
|
+
InputRoot as InputRootPrimitive,
|
|
6
|
+
dataAttributes,
|
|
7
|
+
type IInputFieldProps,
|
|
8
|
+
} from '@cdx-ui/primitives';
|
|
9
|
+
import { cn } from '@cdx-ui/utils';
|
|
10
|
+
import {
|
|
11
|
+
type CurrencyInputVariantProps,
|
|
12
|
+
currencyInputFieldVariants,
|
|
13
|
+
currencyInputRootVariants,
|
|
14
|
+
} from './styles';
|
|
15
|
+
|
|
16
|
+
// Hoisted at module scope — Intl instantiation is expensive (parses locale data).
|
|
17
|
+
const currencyFormatter = new Intl.NumberFormat('en-US', {
|
|
18
|
+
minimumFractionDigits: 2,
|
|
19
|
+
maximumFractionDigits: 2,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Cap the accumulated cents to 15 digits so the integer stays below
|
|
23
|
+
// Number.MAX_SAFE_INTEGER (16 digits). Past that, parsing loses precision and the
|
|
24
|
+
// amount garbles or resets — 15 digits already allows up to $9,999,999,999,999.99.
|
|
25
|
+
const MAX_DIGITS = 15;
|
|
26
|
+
|
|
27
|
+
function centsToAmountDisplay(cents: number): string {
|
|
28
|
+
return currencyFormatter.format(cents / 100);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function valueToCents(value: number | undefined | null): number {
|
|
32
|
+
return Math.round((value ?? 0) * 100);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CurrencyInputProps
|
|
36
|
+
extends
|
|
37
|
+
IInputFieldProps,
|
|
38
|
+
CurrencyInputVariantProps,
|
|
39
|
+
Pick<
|
|
40
|
+
TextInputProps,
|
|
41
|
+
'accessibilityLabel' | 'accessibilityHint' | 'testID' | 'onFocus' | 'onBlur'
|
|
42
|
+
> {
|
|
43
|
+
/** Current numeric value. Renders formatted zero when undefined or null. */
|
|
44
|
+
value?: number;
|
|
45
|
+
/** Called with the parsed number when the user changes the amount. */
|
|
46
|
+
onChangeValue?: (value: number) => void;
|
|
47
|
+
/** Currency symbol rendered as an inline prefix. Defaults to `"$"`. */
|
|
48
|
+
currencySymbol?: string;
|
|
49
|
+
className?: string;
|
|
50
|
+
style?: ViewProps['style'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const CurrencyInput = forwardRef<TextInput, CurrencyInputProps>(
|
|
54
|
+
(
|
|
55
|
+
{
|
|
56
|
+
value,
|
|
57
|
+
onChangeValue,
|
|
58
|
+
currencySymbol = '$',
|
|
59
|
+
size = 'default',
|
|
60
|
+
isDisabled,
|
|
61
|
+
isInvalid,
|
|
62
|
+
isReadOnly,
|
|
63
|
+
isRequired,
|
|
64
|
+
isFocused,
|
|
65
|
+
isHovered,
|
|
66
|
+
isFullWidth,
|
|
67
|
+
className,
|
|
68
|
+
style,
|
|
69
|
+
accessibilityLabel,
|
|
70
|
+
accessibilityHint,
|
|
71
|
+
testID,
|
|
72
|
+
onFocus,
|
|
73
|
+
onBlur,
|
|
74
|
+
},
|
|
75
|
+
ref,
|
|
76
|
+
) => {
|
|
77
|
+
// null = display mode; number = active editing cents.
|
|
78
|
+
// Calculator-style cents accumulation: digits are always extracted from whatever
|
|
79
|
+
// the user types, interpreted as integer cents, then reformatted. The caret is
|
|
80
|
+
// kept at the end at all times so entry behaves like a banking amount pad — new
|
|
81
|
+
// digits append, backspace removes the rightmost digit.
|
|
82
|
+
const [editingCents, setEditingCents] = useState<number | null>(null);
|
|
83
|
+
|
|
84
|
+
// Controlled caret, pinned to the end of the formatted string. `undefined` in
|
|
85
|
+
// display mode so we never fight the platform when the field is not being edited.
|
|
86
|
+
const [selection, setSelection] = useState<{ start: number; end: number }>();
|
|
87
|
+
|
|
88
|
+
type FocusHandler = NonNullable<TextInputProps['onFocus']>;
|
|
89
|
+
type BlurHandler = NonNullable<TextInputProps['onBlur']>;
|
|
90
|
+
type SelectionChangeHandler = NonNullable<TextInputProps['onSelectionChange']>;
|
|
91
|
+
|
|
92
|
+
const displayCents = editingCents !== null ? editingCents : valueToCents(value);
|
|
93
|
+
const amountDisplay = centsToAmountDisplay(displayCents);
|
|
94
|
+
const fullDisplay = `${currencySymbol}${amountDisplay}`;
|
|
95
|
+
const isEmpty = displayCents === 0;
|
|
96
|
+
|
|
97
|
+
// Index just past the last character of the formatted string for a given cents.
|
|
98
|
+
// Computed from `cents` (not the render-closure `fullDisplay`) so the caret is
|
|
99
|
+
// correct in the same commit as a value change, even when a reformat adds or
|
|
100
|
+
// removes a grouping comma (e.g. 444.44 → 3,444.44) and changes the length.
|
|
101
|
+
const caretIndexFor = (cents: number) =>
|
|
102
|
+
`${currencySymbol}${centsToAmountDisplay(cents)}`.length;
|
|
103
|
+
|
|
104
|
+
const pinCaret = (end: number) => setSelection({ start: end, end });
|
|
105
|
+
|
|
106
|
+
const handleFocus: FocusHandler = (e) => {
|
|
107
|
+
// Initialise from the current value so existing amounts are preserved on focus.
|
|
108
|
+
const cents = valueToCents(value);
|
|
109
|
+
setEditingCents(cents);
|
|
110
|
+
pinCaret(caretIndexFor(cents));
|
|
111
|
+
onFocus?.(e);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handleBlur: BlurHandler = (e) => {
|
|
115
|
+
setEditingCents(null);
|
|
116
|
+
setSelection(undefined);
|
|
117
|
+
onBlur?.(e);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleChangeText = (text: string) => {
|
|
121
|
+
// Extract only digit characters, interpret the resulting integer as cents.
|
|
122
|
+
// e.g. user typing '1' after '0.00' → digits '0001' → 1 cent → 0.01
|
|
123
|
+
// Backspace on '1.23' → '1.2' → digits '12' → 12 cents → 0.12
|
|
124
|
+
// Digits past the cap are dropped, so extra keystrokes are ignored rather
|
|
125
|
+
// than overflowing into an unsafe integer.
|
|
126
|
+
const digits = text.replace(/\D/g, '').slice(0, MAX_DIGITS);
|
|
127
|
+
const cents = parseInt(digits || '0', 10);
|
|
128
|
+
setEditingCents(cents);
|
|
129
|
+
pinCaret(caretIndexFor(cents));
|
|
130
|
+
onChangeValue?.(cents / 100);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const handleSelectionChange: SelectionChangeHandler = (e) => {
|
|
134
|
+
// Re-pin the caret to the end ONLY when it drifts to the LEFT of the end while
|
|
135
|
+
// editing — e.g. web hardware-keyboard arrow keys or a click/tap into the middle
|
|
136
|
+
// (native decimal-pad keyboards have no arrow keys, so those platforms rarely
|
|
137
|
+
// trigger this). Value-driven changes already pin the caret via
|
|
138
|
+
// `handleChangeText`/`handleFocus`.
|
|
139
|
+
//
|
|
140
|
+
// Crucially we do NOT touch the caret when it sits at or past the last-rendered
|
|
141
|
+
// end. While typing, the native caret momentarily lands one past our last render
|
|
142
|
+
// (the new value hasn't committed yet); re-pinning there would tug it back to the
|
|
143
|
+
// stale, one-shorter end and yank it one position left — the classic controlled-
|
|
144
|
+
// selection race. Ignoring forward positions sidesteps that entirely, and also
|
|
145
|
+
// prevents a feedback loop (the pin re-emits a selectionChange already at the end).
|
|
146
|
+
if (editingCents === null) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const end = fullDisplay.length;
|
|
150
|
+
const { selection: current } = e.nativeEvent;
|
|
151
|
+
if (current.end < end) {
|
|
152
|
+
pinCaret(end);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<InputRootPrimitive
|
|
158
|
+
isDisabled={isDisabled}
|
|
159
|
+
isInvalid={isInvalid}
|
|
160
|
+
isReadOnly={isReadOnly}
|
|
161
|
+
isRequired={isRequired}
|
|
162
|
+
isFocused={isFocused}
|
|
163
|
+
isHovered={isHovered}
|
|
164
|
+
isFullWidth={isFullWidth}
|
|
165
|
+
className={cn(currencyInputRootVariants({ size }), className)}
|
|
166
|
+
style={style}
|
|
167
|
+
{...dataAttributes({ slot: 'currency-input' })}
|
|
168
|
+
>
|
|
169
|
+
<InputFieldPrimitive
|
|
170
|
+
ref={ref}
|
|
171
|
+
value={fullDisplay}
|
|
172
|
+
selection={selection}
|
|
173
|
+
onChangeText={handleChangeText}
|
|
174
|
+
onSelectionChange={handleSelectionChange}
|
|
175
|
+
keyboardType="decimal-pad"
|
|
176
|
+
accessibilityLabel={accessibilityLabel}
|
|
177
|
+
accessibilityHint={accessibilityHint}
|
|
178
|
+
accessibilityValue={{ text: fullDisplay }}
|
|
179
|
+
testID={testID}
|
|
180
|
+
onFocus={handleFocus}
|
|
181
|
+
onBlur={handleBlur}
|
|
182
|
+
className={cn(
|
|
183
|
+
currencyInputFieldVariants({ size }),
|
|
184
|
+
isEmpty ? 'text-content-tertiary' : 'text-content-primary',
|
|
185
|
+
)}
|
|
186
|
+
/>
|
|
187
|
+
</InputRootPrimitive>
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
CurrencyInput.displayName = 'CurrencyInput';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
import { DISABLED_CURSOR } from '../../styles/primitives';
|
|
3
|
+
|
|
4
|
+
export const currencyInputRootVariants = cva(
|
|
5
|
+
[
|
|
6
|
+
'w-full items-center justify-center',
|
|
7
|
+
'data-[disabled=true]:opacity-[var(--opacity-disabled)]',
|
|
8
|
+
DISABLED_CURSOR,
|
|
9
|
+
'data-[readonly=true]:cursor-default',
|
|
10
|
+
],
|
|
11
|
+
{
|
|
12
|
+
variants: {
|
|
13
|
+
size: {
|
|
14
|
+
default: 'py-1',
|
|
15
|
+
small: 'py-0.5',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
size: 'default',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const currencyInputFieldVariants = cva(
|
|
25
|
+
[
|
|
26
|
+
'w-full bg-transparent p-0 text-center',
|
|
27
|
+
'placeholder:text-content-tertiary',
|
|
28
|
+
'web:outline-none',
|
|
29
|
+
'data-[invalid=true]:text-content-danger',
|
|
30
|
+
],
|
|
31
|
+
{
|
|
32
|
+
variants: {
|
|
33
|
+
size: {
|
|
34
|
+
default: 'heading-xl',
|
|
35
|
+
small: 'heading-lg',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
defaultVariants: {
|
|
39
|
+
size: 'default',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
export type CurrencyInputVariantProps = VariantProps<typeof currencyInputRootVariants>;
|
|
@@ -46,7 +46,7 @@ export const linkNativeUnderlineVariants = cva(['border-b border-content-link pb
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
export const linkIconVariants = cva(
|
|
49
|
-
['size-4', Platform.select({ web: 'text-
|
|
49
|
+
['size-4', Platform.select({ web: 'text-current', default: 'text-content-link' })],
|
|
50
50
|
{
|
|
51
51
|
variants: {
|
|
52
52
|
visited: {
|
|
@@ -19,10 +19,8 @@ export const selectTriggerVariants = cva(
|
|
|
19
19
|
'web:hover:data-[focus=false]:data-[invalid=false]:border-stroke-hover',
|
|
20
20
|
'data-[focus=true]:border-[length:var(--border-width-focused)]',
|
|
21
21
|
'data-[focus=true]:data-[invalid=false]:border-stroke-action',
|
|
22
|
-
'data-[focus=true]:data-[invalid=true]:border-stroke-danger',
|
|
23
22
|
'data-[state=open]:border-[length:var(--border-width-focused)]',
|
|
24
23
|
'data-[state=open]:data-[invalid=false]:border-stroke-action',
|
|
25
|
-
'data-[state=open]:data-[invalid=true]:border-stroke-danger',
|
|
26
24
|
'data-[invalid=true]:border-stroke-danger',
|
|
27
25
|
],
|
|
28
26
|
{
|
package/src/components/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import type { MetroConfig } from 'metro-config';
|
|
2
3
|
import { withUniwindConfig } from 'uniwind/metro';
|
|
3
4
|
|
|
@@ -22,8 +23,60 @@ export function withForgeMetroConfig<T extends MetroConfig>(
|
|
|
22
23
|
config: T,
|
|
23
24
|
options: ForgeMetroConfigOptions,
|
|
24
25
|
): T {
|
|
25
|
-
|
|
26
|
+
const withUniwind = withUniwindConfig(config, {
|
|
26
27
|
cssEntryFile: options.cssEntryFile,
|
|
27
28
|
dtsFile: options.generatedTypesFile,
|
|
28
29
|
}) as T;
|
|
30
|
+
|
|
31
|
+
return withUniwindMultiCopyFix(withUniwind);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fixes Uniwind 1.9's nativeResolver in pnpm monorepos with multiple Uniwind copies.
|
|
36
|
+
*
|
|
37
|
+
* Uniwind decides whether to wrap a `react-native` import by comparing
|
|
38
|
+
* `originModulePath` against a cached internal base path resolved via
|
|
39
|
+
* `require.resolve('uniwind/package.json')`. In pnpm, the Metro config loaded
|
|
40
|
+
* by Node and the Uniwind copy bundled for the app may resolve to different
|
|
41
|
+
* physical paths (different peer-dep hashes in the virtual store), so the
|
|
42
|
+
* `isInternal` check returns false for Uniwind-internal code. The
|
|
43
|
+
* nativeResolver then re-routes `react-native` to `uniwind/components`, which
|
|
44
|
+
* re-exports from `react-native` — a circular `get NativeModules` getter that
|
|
45
|
+
* overflows the stack at runtime.
|
|
46
|
+
*
|
|
47
|
+
* Fix: intercept `react-native` requests originating from inside any Uniwind
|
|
48
|
+
* copy and resolve them from the app's `projectRoot`, bypassing the broken
|
|
49
|
+
* `isInternal` check entirely.
|
|
50
|
+
*/
|
|
51
|
+
function withUniwindMultiCopyFix<T extends MetroConfig>(config: T): T {
|
|
52
|
+
const resolver = config.resolver;
|
|
53
|
+
if (!resolver) return config;
|
|
54
|
+
|
|
55
|
+
const innerResolveRequest = resolver.resolveRequest;
|
|
56
|
+
const uniwindPathFragment = `${path.sep}node_modules${path.sep}uniwind${path.sep}`;
|
|
57
|
+
const projectRoot = config.projectRoot;
|
|
58
|
+
const appOriginModulePath = projectRoot ? path.join(projectRoot, 'package.json') : undefined;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
...config,
|
|
62
|
+
resolver: {
|
|
63
|
+
...resolver,
|
|
64
|
+
resolveRequest: (context, moduleName, platform) => {
|
|
65
|
+
const fromUniwind = context.originModulePath?.includes(uniwindPathFragment) ?? false;
|
|
66
|
+
if (
|
|
67
|
+
fromUniwind &&
|
|
68
|
+
(moduleName === 'react-native' || moduleName.startsWith('react-native/'))
|
|
69
|
+
) {
|
|
70
|
+
const rewrittenContext = appOriginModulePath
|
|
71
|
+
? { ...context, originModulePath: appOriginModulePath }
|
|
72
|
+
: context;
|
|
73
|
+
return context.resolveRequest(rewrittenContext, moduleName, platform);
|
|
74
|
+
}
|
|
75
|
+
if (innerResolveRequest) {
|
|
76
|
+
return innerResolveRequest(context, moduleName, platform);
|
|
77
|
+
}
|
|
78
|
+
return context.resolveRequest(context, moduleName, platform);
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
} as T;
|
|
29
82
|
}
|