@bacon-pos/ui 0.4.0 → 0.5.1

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/CodeField/CodeField.js +104 -66
  2. package/lib/commonjs/components/CodeField/CodeField.js.map +1 -1
  3. package/lib/commonjs/components/CodeField/CodeField.stories.js +38 -2
  4. package/lib/commonjs/components/CodeField/CodeField.stories.js.map +1 -1
  5. package/lib/commonjs/components/CodeField/CodeField.test.js +28 -0
  6. package/lib/commonjs/components/CodeField/CodeField.test.js.map +1 -1
  7. package/lib/commonjs/components/Keypad/Keypad.js +162 -0
  8. package/lib/commonjs/components/Keypad/Keypad.js.map +1 -0
  9. package/lib/commonjs/components/Keypad/Keypad.stories.js +210 -0
  10. package/lib/commonjs/components/Keypad/Keypad.stories.js.map +1 -0
  11. package/lib/commonjs/components/Keypad/Keypad.test.js +119 -0
  12. package/lib/commonjs/components/Keypad/Keypad.test.js.map +1 -0
  13. package/lib/commonjs/components/Keypad/index.js +13 -0
  14. package/lib/commonjs/components/Keypad/index.js.map +1 -0
  15. package/lib/commonjs/components/index.js +20 -9
  16. package/lib/commonjs/components/index.js.map +1 -1
  17. package/lib/module/components/CodeField/CodeField.js +104 -66
  18. package/lib/module/components/CodeField/CodeField.js.map +1 -1
  19. package/lib/module/components/CodeField/CodeField.stories.js +37 -1
  20. package/lib/module/components/CodeField/CodeField.stories.js.map +1 -1
  21. package/lib/module/components/CodeField/CodeField.test.js +28 -0
  22. package/lib/module/components/CodeField/CodeField.test.js.map +1 -1
  23. package/lib/module/components/Keypad/Keypad.js +158 -0
  24. package/lib/module/components/Keypad/Keypad.js.map +1 -0
  25. package/lib/module/components/Keypad/Keypad.stories.js +206 -0
  26. package/lib/module/components/Keypad/Keypad.stories.js.map +1 -0
  27. package/lib/module/components/Keypad/Keypad.test.js +119 -0
  28. package/lib/module/components/Keypad/Keypad.test.js.map +1 -0
  29. package/lib/module/components/Keypad/index.js +4 -0
  30. package/lib/module/components/Keypad/index.js.map +1 -0
  31. package/lib/module/components/index.js +1 -0
  32. package/lib/module/components/index.js.map +1 -1
  33. package/lib/typescript/commonjs/components/CodeField/CodeField.d.ts +8 -2
  34. package/lib/typescript/commonjs/components/CodeField/CodeField.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/components/Keypad/Keypad.d.ts +54 -0
  36. package/lib/typescript/commonjs/components/Keypad/Keypad.d.ts.map +1 -0
  37. package/lib/typescript/commonjs/components/Keypad/index.d.ts +3 -0
  38. package/lib/typescript/commonjs/components/Keypad/index.d.ts.map +1 -0
  39. package/lib/typescript/commonjs/components/index.d.ts +1 -0
  40. package/lib/typescript/commonjs/components/index.d.ts.map +1 -1
  41. package/lib/typescript/module/components/CodeField/CodeField.d.ts +8 -2
  42. package/lib/typescript/module/components/CodeField/CodeField.d.ts.map +1 -1
  43. package/lib/typescript/module/components/Keypad/Keypad.d.ts +54 -0
  44. package/lib/typescript/module/components/Keypad/Keypad.d.ts.map +1 -0
  45. package/lib/typescript/module/components/Keypad/index.d.ts +3 -0
  46. package/lib/typescript/module/components/Keypad/index.d.ts.map +1 -0
  47. package/lib/typescript/module/components/index.d.ts +1 -0
  48. package/lib/typescript/module/components/index.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/CodeField/CodeField.tsx +108 -65
  51. package/src/components/Keypad/Keypad.tsx +191 -0
  52. package/src/components/Keypad/index.ts +2 -0
  53. package/src/components/index.ts +1 -0
@@ -13,6 +13,16 @@ import { Text } from '../Text/Text';
13
13
  /** Removes the browser's default focus outline on web (no-op on native). */
14
14
  const WEB_NO_OUTLINE = { outlineStyle: 'none', outlineWidth: 0 } as unknown as TextStyle;
15
15
 
16
+ /** Convert a `#RRGGBB` (or `#RGB`) token to an `rgba()` string at the given alpha. */
17
+ function hexToRgba(hex: string, alpha: number): string {
18
+ const h = hex.replace('#', '');
19
+ const full = h.length === 3 ? h.replace(/./g, (c) => c + c) : h;
20
+ const r = parseInt(full.slice(0, 2), 16);
21
+ const g = parseInt(full.slice(2, 4), 16);
22
+ const b = parseInt(full.slice(4, 6), 16);
23
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
24
+ }
25
+
16
26
  export type CodeFieldMode = 'numeric' | 'alpha' | 'alphanumeric';
17
27
 
18
28
  /** Chars to strip per mode (applied after upper-casing). */
@@ -33,12 +43,18 @@ export interface CodeFieldProps {
33
43
  mode?: CodeFieldMode;
34
44
  /** Called once every cell is filled. */
35
45
  onComplete?: (value: string) => void;
46
+ /** Helper text below the cells. Replaced by the error message when `error` is a string. */
47
+ helperText?: string;
48
+ /** Mark the field invalid. Pass a string to show it as the error message. */
49
+ error?: boolean | string;
36
50
  /** Disable input and dim the cells. */
37
51
  disabled?: boolean;
38
52
  /** Focus on mount. */
39
53
  autoFocus?: boolean;
40
- /** Cell width in px (Figma default 126). */
54
+ /** Cell width in px (Figma default 126). Ignored when `fullWidth` is set. */
41
55
  cellWidth?: number;
56
+ /** Stretch the row to 100% of the container; cells share the width evenly. */
57
+ fullWidth?: boolean;
42
58
  /** Cell height in px (Figma default 58). */
43
59
  cellHeight?: number;
44
60
  /** Gap between cells in px (Figma default 24). */
@@ -65,9 +81,12 @@ export function CodeField({
65
81
  length = 6,
66
82
  mode = 'numeric',
67
83
  onComplete,
84
+ helperText,
85
+ error,
68
86
  disabled = false,
69
87
  autoFocus = false,
70
88
  cellWidth = 126,
89
+ fullWidth = false,
71
90
  cellHeight = 58,
72
91
  gap = 24,
73
92
  fontFamily,
@@ -86,75 +105,99 @@ export function CodeField({
86
105
  };
87
106
 
88
107
  const activeIndex = Math.min(value.length, length - 1);
108
+ const hasError = Boolean(error);
109
+ const message = typeof error === 'string' ? error : helperText;
89
110
 
90
111
  return (
91
- <Pressable
92
- onPress={() => inputRef.current?.focus()}
93
- disabled={disabled}
94
- style={[{ position: 'relative', alignSelf: 'flex-start' }, style]}
95
- >
96
- <View style={{ flexDirection: 'row', gap }}>
97
- {Array.from({ length }).map((_, i) => {
98
- const isActive = focused && i === activeIndex && !disabled;
99
- return (
100
- <View
101
- key={i}
102
- testID="code-field-cell"
103
- style={{
104
- width: cellWidth,
105
- height: cellHeight,
106
- borderRadius: theme.radii.pill,
107
- borderWidth: 1,
108
- borderColor: isActive ? theme.colors.border.focus : theme.colors.border.default,
109
- backgroundColor: disabled
110
- ? theme.colors.disabled.background
111
- : theme.colors.background.default,
112
- alignItems: 'center',
113
- justifyContent: 'center',
114
- opacity: disabled ? 0.6 : 1,
115
- ...(isActive
116
- ? {
117
- boxShadow: [
118
- { offsetX: 0, offsetY: 0, blurRadius: 0, spreadDistance: 6, color: 'rgba(201,255,0,0.15)' },
119
- ] as ViewStyle['boxShadow'],
120
- }
121
- : null),
122
- }}
123
- >
124
- <Text
125
- color={disabled ? 'tertiary' : 'primary'}
112
+ <View style={[fullWidth ? { alignSelf: 'stretch', width: '100%' } : { alignSelf: 'flex-start' }, style]}>
113
+ <Pressable
114
+ onPress={() => inputRef.current?.focus()}
115
+ disabled={disabled}
116
+ style={{ position: 'relative' }}
117
+ >
118
+ <View style={{ flexDirection: 'row', gap }}>
119
+ {Array.from({ length }).map((_, i) => {
120
+ const isActive = focused && i === activeIndex && !disabled;
121
+ // Error overrides the default/focus border on every cell; the active
122
+ // cell still gets the ring (red on error, lime otherwise).
123
+ const borderColor = hasError
124
+ ? theme.colors.feedback.error
125
+ : isActive
126
+ ? theme.colors.border.focus
127
+ : theme.colors.border.default;
128
+ const ringColor = hasError
129
+ ? hexToRgba(theme.colors.feedback.error, 0.15)
130
+ : 'rgba(201,255,0,0.15)';
131
+ return (
132
+ <View
133
+ key={i}
134
+ testID="code-field-cell"
126
135
  style={{
127
- fontFamily: fontFamily ?? theme.fontFamily.regular,
128
- fontSize,
129
- lineHeight: Math.round(fontSize * 1.45),
136
+ ...(fullWidth ? { flex: 1 } : { width: cellWidth }),
137
+ height: cellHeight,
138
+ borderRadius: theme.radii.pill,
139
+ borderWidth: 1,
140
+ borderColor,
141
+ backgroundColor: disabled
142
+ ? theme.colors.disabled.background
143
+ : theme.colors.background.default,
144
+ alignItems: 'center',
145
+ justifyContent: 'center',
146
+ opacity: disabled ? 0.6 : 1,
147
+ ...(isActive
148
+ ? {
149
+ boxShadow: [
150
+ { offsetX: 0, offsetY: 0, blurRadius: 0, spreadDistance: 6, color: ringColor },
151
+ ] as ViewStyle['boxShadow'],
152
+ }
153
+ : null),
130
154
  }}
131
155
  >
132
- {value[i] ?? ''}
133
- </Text>
134
- </View>
135
- );
136
- })}
137
- </View>
156
+ <Text
157
+ color={disabled ? 'tertiary' : 'primary'}
158
+ style={{
159
+ fontFamily: fontFamily ?? theme.fontFamily.regular,
160
+ fontSize,
161
+ lineHeight: Math.round(fontSize * 1.45),
162
+ }}
163
+ >
164
+ {value[i] ?? ''}
165
+ </Text>
166
+ </View>
167
+ );
168
+ })}
169
+ </View>
170
+
171
+ <TextInput
172
+ ref={inputRef}
173
+ value={value}
174
+ onChangeText={handleChange}
175
+ onFocus={() => setFocused(true)}
176
+ onBlur={() => setFocused(false)}
177
+ keyboardType={mode === 'numeric' ? 'number-pad' : 'default'}
178
+ autoCapitalize="characters"
179
+ autoCorrect={false}
180
+ maxLength={length}
181
+ editable={!disabled}
182
+ caretHidden
183
+ autoFocus={autoFocus}
184
+ accessibilityLabel={accessibilityLabel}
185
+ style={[
186
+ { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0 },
187
+ WEB_NO_OUTLINE,
188
+ ]}
189
+ />
190
+ </Pressable>
138
191
 
139
- <TextInput
140
- ref={inputRef}
141
- value={value}
142
- onChangeText={handleChange}
143
- onFocus={() => setFocused(true)}
144
- onBlur={() => setFocused(false)}
145
- keyboardType={mode === 'numeric' ? 'number-pad' : 'default'}
146
- autoCapitalize="characters"
147
- autoCorrect={false}
148
- maxLength={length}
149
- editable={!disabled}
150
- caretHidden
151
- autoFocus={autoFocus}
152
- accessibilityLabel={accessibilityLabel}
153
- style={[
154
- { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity: 0 },
155
- WEB_NO_OUTLINE,
156
- ]}
157
- />
158
- </Pressable>
192
+ {message ? (
193
+ <Text
194
+ variant="bodySmall"
195
+ color={hasError ? theme.colors.feedback.error : 'tertiary'}
196
+ style={{ marginTop: 8, marginLeft: 8 }}
197
+ >
198
+ {message}
199
+ </Text>
200
+ ) : null}
201
+ </View>
159
202
  );
160
203
  }
@@ -0,0 +1,191 @@
1
+ import { type ReactNode } from 'react';
2
+ import { Pressable, View, type StyleProp, type ViewStyle } from 'react-native';
3
+ import { useTheme } from '../../theme';
4
+ import { Icon } from '../Icon/Icon';
5
+ import type { IconName } from '../Icon/icons.generated';
6
+ import { Text } from '../Text/Text';
7
+
8
+ /** The optional key to the left of 0. Hidden unless provided. */
9
+ export interface KeypadExtraKey {
10
+ /** Short label (e.g. "."). */
11
+ label?: string;
12
+ /** Or an icon. */
13
+ icon?: IconName;
14
+ /** If set, pressing appends this to the value (e.g. "." for decimals). */
15
+ value?: string;
16
+ /** Custom action (open a modal, navigate, …). Overrides `value`. */
17
+ onPress?: () => void;
18
+ /** Content color. */
19
+ color?: string;
20
+ /** Key background color. */
21
+ backgroundColor?: string;
22
+ }
23
+
24
+ export interface KeypadProps {
25
+ /** Controlled value being entered. Defaults to ''. */
26
+ value?: string;
27
+ /** Called with the next value on digit / backspace / extra-value press. */
28
+ onChangeText?: (value: string) => void;
29
+ /** Fired on every press with the raw key: '0'–'9', 'backspace', or 'extra'. */
30
+ onKeyPress?: (key: string) => void;
31
+ /** Max length of `value`. */
32
+ maxLength?: number;
33
+ /** Content shown above the keys (dots, amount, anything). Swappable per use case. */
34
+ header?: ReactNode;
35
+ /** The key left of 0. Hidden by default; provide to show and customize it. */
36
+ extraKey?: KeypadExtraKey;
37
+ /** Key height. Defaults to 83. */
38
+ keyHeight?: number;
39
+ /** Gap between keys. Defaults to 8. */
40
+ gap?: number;
41
+ /** Total width. Defaults to full width; the 3 columns split evenly. */
42
+ width?: number;
43
+ /** Digit font size. Defaults to 24. */
44
+ digitSize?: number;
45
+ /** Space between header and keys. Defaults to 24. */
46
+ headerGap?: number;
47
+ /** Disable all keys. */
48
+ disabled?: boolean;
49
+ style?: StyleProp<ViewStyle>;
50
+ }
51
+
52
+ const ROWS = [
53
+ ['1', '2', '3'],
54
+ ['4', '5', '6'],
55
+ ['7', '8', '9'],
56
+ ];
57
+
58
+ /**
59
+ * Numeric keypad — a 3-column pad decoupled from what it drives (bind it to a
60
+ * PIN display, an amount, etc. via `value`/`onChangeText`, and put that display
61
+ * in `header`). Backspace is fixed to the right of 0; the key left of 0 is hidden
62
+ * unless you pass `extraKey` (custom symbol/icon, action and colors). Width,
63
+ * key height, gap and digit size are all configurable.
64
+ */
65
+ export function Keypad({
66
+ value = '',
67
+ onChangeText,
68
+ onKeyPress,
69
+ maxLength,
70
+ header,
71
+ extraKey,
72
+ keyHeight = 83,
73
+ gap = 8,
74
+ width,
75
+ digitSize = 24,
76
+ headerGap = 24,
77
+ disabled = false,
78
+ style,
79
+ }: KeypadProps) {
80
+ const theme = useTheme();
81
+
82
+ const keyStyle = (bg?: string): StyleProp<ViewStyle> => ({
83
+ flex: 1,
84
+ height: keyHeight,
85
+ borderRadius: theme.radii.xl,
86
+ borderWidth: 1,
87
+ borderColor: theme.colors.border.strong,
88
+ backgroundColor: bg ?? theme.colors.action.secondary,
89
+ alignItems: 'center',
90
+ justifyContent: 'center',
91
+ });
92
+
93
+ const pressDigit = (digit: string) => {
94
+ onKeyPress?.(digit);
95
+ if (maxLength != null && value.length >= maxLength) return;
96
+ onChangeText?.(value + digit);
97
+ };
98
+
99
+ const pressBackspace = () => {
100
+ onKeyPress?.('backspace');
101
+ onChangeText?.(value.slice(0, -1));
102
+ };
103
+
104
+ const pressExtra = () => {
105
+ onKeyPress?.('extra');
106
+ if (extraKey?.onPress) {
107
+ extraKey.onPress();
108
+ return;
109
+ }
110
+ if (extraKey?.value != null) {
111
+ if (maxLength != null && value.length >= maxLength) return;
112
+ onChangeText?.(value + extraKey.value);
113
+ }
114
+ };
115
+
116
+ const DigitKey = ({ digit }: { digit: string }) => (
117
+ <Pressable
118
+ onPress={() => pressDigit(digit)}
119
+ disabled={disabled}
120
+ accessibilityRole="button"
121
+ accessibilityLabel={digit}
122
+ style={({ pressed }) => [
123
+ keyStyle(),
124
+ { opacity: disabled ? 0.5 : pressed ? 0.85 : 1 },
125
+ ]}
126
+ >
127
+ <Text
128
+ variant="h2"
129
+ color={theme.colors.action.secondaryForeground}
130
+ style={{ fontSize: digitSize, lineHeight: Math.round(digitSize * 1.35) }}
131
+ >
132
+ {digit}
133
+ </Text>
134
+ </Pressable>
135
+ );
136
+
137
+ return (
138
+ <View style={[{ width: width ?? '100%', gap }, style]}>
139
+ {header != null ? <View style={{ marginBottom: headerGap - gap }}>{header}</View> : null}
140
+
141
+ {ROWS.map((row) => (
142
+ <View key={row[0]} style={{ flexDirection: 'row', gap }}>
143
+ {row.map((digit) => (
144
+ <DigitKey key={digit} digit={digit} />
145
+ ))}
146
+ </View>
147
+ ))}
148
+
149
+ <View style={{ flexDirection: 'row', gap }}>
150
+ {extraKey ? (
151
+ <Pressable
152
+ onPress={pressExtra}
153
+ disabled={disabled}
154
+ accessibilityRole="button"
155
+ accessibilityLabel={extraKey.label ?? 'action'}
156
+ style={({ pressed }) => [
157
+ keyStyle(extraKey.backgroundColor),
158
+ { opacity: disabled ? 0.5 : pressed ? 0.85 : 1 },
159
+ ]}
160
+ >
161
+ {extraKey.icon ? (
162
+ <Icon name={extraKey.icon} size={24} color={extraKey.color ?? theme.colors.action.secondaryForeground} />
163
+ ) : (
164
+ <Text
165
+ variant="h2"
166
+ color={extraKey.color ?? theme.colors.action.secondaryForeground}
167
+ style={{ fontSize: digitSize, lineHeight: Math.round(digitSize * 1.35) }}
168
+ >
169
+ {extraKey.label ?? ''}
170
+ </Text>
171
+ )}
172
+ </Pressable>
173
+ ) : (
174
+ <View style={{ flex: 1 }} />
175
+ )}
176
+
177
+ <DigitKey digit="0" />
178
+
179
+ <Pressable
180
+ onPress={pressBackspace}
181
+ disabled={disabled}
182
+ accessibilityRole="button"
183
+ accessibilityLabel="Backspace"
184
+ style={({ pressed }) => [keyStyle(), { opacity: disabled ? 0.5 : pressed ? 0.85 : 1 }]}
185
+ >
186
+ <Icon name="arrow-left" size={24} color={theme.colors.action.secondaryForeground} />
187
+ </Pressable>
188
+ </View>
189
+ </View>
190
+ );
191
+ }
@@ -0,0 +1,2 @@
1
+ export { Keypad } from './Keypad';
2
+ export type { KeypadProps, KeypadExtraKey } from './Keypad';
@@ -10,6 +10,7 @@ export * from './Dialog';
10
10
  export * from './Icon';
11
11
  export * from './IconButton';
12
12
  export * from './Input';
13
+ export * from './Keypad';
13
14
  export * from './LinkButton';
14
15
  export * from './ProductCard';
15
16
  export * from './QuantityStepper';