@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.
Files changed (53) hide show
  1. package/lib/commonjs/components/BottomSheet/index.js +68 -25
  2. package/lib/commonjs/components/BottomSheet/index.js.map +1 -1
  3. package/lib/commonjs/components/BottomSheet/styles.js +5 -42
  4. package/lib/commonjs/components/BottomSheet/styles.js.map +1 -1
  5. package/lib/commonjs/components/CurrencyInput/index.js +157 -0
  6. package/lib/commonjs/components/CurrencyInput/index.js.map +1 -0
  7. package/lib/commonjs/components/CurrencyInput/styles.js +31 -0
  8. package/lib/commonjs/components/CurrencyInput/styles.js.map +1 -0
  9. package/lib/commonjs/components/Link/styles.js +1 -1
  10. package/lib/commonjs/components/Select/styles.js +1 -1
  11. package/lib/commonjs/components/Select/styles.js.map +1 -1
  12. package/lib/commonjs/components/index.js +12 -0
  13. package/lib/commonjs/components/index.js.map +1 -1
  14. package/lib/commonjs/metro/withForgeMetroConfig.js +50 -1
  15. package/lib/commonjs/metro/withForgeMetroConfig.js.map +1 -1
  16. package/lib/commonjs/styles.css +0 -5
  17. package/lib/module/components/BottomSheet/index.js +70 -27
  18. package/lib/module/components/BottomSheet/index.js.map +1 -1
  19. package/lib/module/components/BottomSheet/styles.js +4 -42
  20. package/lib/module/components/BottomSheet/styles.js.map +1 -1
  21. package/lib/module/components/CurrencyInput/index.js +154 -0
  22. package/lib/module/components/CurrencyInput/index.js.map +1 -0
  23. package/lib/module/components/CurrencyInput/styles.js +27 -0
  24. package/lib/module/components/CurrencyInput/styles.js.map +1 -0
  25. package/lib/module/components/Link/styles.js +1 -1
  26. package/lib/module/components/Select/styles.js +1 -1
  27. package/lib/module/components/Select/styles.js.map +1 -1
  28. package/lib/module/components/index.js +1 -0
  29. package/lib/module/components/index.js.map +1 -1
  30. package/lib/module/metro/withForgeMetroConfig.js +49 -1
  31. package/lib/module/metro/withForgeMetroConfig.js.map +1 -1
  32. package/lib/module/styles.css +0 -5
  33. package/lib/typescript/components/BottomSheet/index.d.ts.map +1 -1
  34. package/lib/typescript/components/BottomSheet/styles.d.ts +0 -5
  35. package/lib/typescript/components/BottomSheet/styles.d.ts.map +1 -1
  36. package/lib/typescript/components/CurrencyInput/index.d.ts +15 -0
  37. package/lib/typescript/components/CurrencyInput/index.d.ts.map +1 -0
  38. package/lib/typescript/components/CurrencyInput/styles.d.ts +9 -0
  39. package/lib/typescript/components/CurrencyInput/styles.d.ts.map +1 -0
  40. package/lib/typescript/components/Select/styles.d.ts.map +1 -1
  41. package/lib/typescript/components/index.d.ts +1 -0
  42. package/lib/typescript/components/index.d.ts.map +1 -1
  43. package/lib/typescript/metro/withForgeMetroConfig.d.ts.map +1 -1
  44. package/package.json +5 -5
  45. package/src/components/BottomSheet/index.tsx +99 -42
  46. package/src/components/BottomSheet/styles.ts +7 -47
  47. package/src/components/CurrencyInput/index.tsx +192 -0
  48. package/src/components/CurrencyInput/styles.ts +44 -0
  49. package/src/components/Link/styles.ts +1 -1
  50. package/src/components/Select/styles.ts +0 -2
  51. package/src/components/index.ts +1 -0
  52. package/src/metro/withForgeMetroConfig.ts +54 -1
  53. 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-inherit', default: 'text-content-link' })],
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
  {
@@ -7,6 +7,7 @@ export * from './Button';
7
7
  export * from './Card';
8
8
  export * from './Checkbox';
9
9
  export * from './Chip';
10
+ export * from './CurrencyInput';
10
11
  export * from './Dialog';
11
12
  export * from './Divider';
12
13
  export * from './Field';
@@ -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
- return withUniwindConfig(config, {
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
  }
package/src/styles.css CHANGED
@@ -1,6 +1 @@
1
1
  @source './**/*.{ts,tsx}';
2
-
3
- /* TODO: Can we keep this? Check with Michael/Jack */
4
- *:focus {
5
- outline: none;
6
- }