@scripso-homepad/ui 0.3.7 → 0.3.9

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.
@@ -7,6 +7,7 @@ import {
7
7
  type ReactNode,
8
8
  } from "react";
9
9
  import {
10
+ Pressable,
10
11
  StyleSheet,
11
12
  Text,
12
13
  TextInput,
@@ -18,6 +19,8 @@ import {
18
19
  type TextStyle,
19
20
  type ViewStyle,
20
21
  } from "react-native";
22
+ import { EyeIcon } from "../icons/EyeIcon";
23
+ import { EyeOffIcon } from "../icons/EyeOffIcon";
21
24
  import {
22
25
  getInputFieldStyles,
23
26
  inputFieldMetrics,
@@ -58,10 +61,16 @@ export interface InputProps extends TextInputProps {
58
61
  containerStyle?: StyleProp<ViewStyle>;
59
62
  style?: StyleProp<TextStyle>;
60
63
  className?: string;
64
+ /** CSS classes for the bordered field container (web). */
65
+ fieldClassName?: string;
66
+ /** Styles merged into the bordered field container. */
67
+ fieldStyle?: StyleProp<ViewStyle>;
61
68
  labelClassName?: string;
62
69
  inputClassName?: string;
63
70
  errorClassName?: string;
64
71
  hintClassName?: string;
72
+ /** When `secureTextEntry` is set, show a toggleable eye icon. Pass `false` to disable. */
73
+ showPasswordToggle?: boolean;
65
74
  }
66
75
 
67
76
  export function Input({
@@ -73,25 +82,37 @@ export function Input({
73
82
  containerStyle,
74
83
  style,
75
84
  className,
85
+ fieldClassName,
86
+ fieldStyle,
76
87
  labelClassName,
77
88
  inputClassName,
78
89
  errorClassName,
79
90
  hintClassName,
80
91
  editable = true,
92
+ secureTextEntry,
93
+ showPasswordToggle,
81
94
  onFocus,
82
95
  onBlur,
83
96
  ...props
84
97
  }: InputProps) {
85
98
  const wrapperRef = useRef<ComponentRef<typeof View>>(null);
99
+ const fieldRef = useRef<ComponentRef<typeof View>>(null);
86
100
  const inputRef = useRef<ComponentRef<typeof TextInput>>(null);
87
101
  const helperRef = useRef<ComponentRef<typeof Text>>(null);
88
102
  const [focused, setFocused] = useState(false);
103
+ const [passwordVisible, setPasswordVisible] = useState(false);
89
104
 
90
105
  useApplyWebClassName(wrapperRef, className);
106
+ useApplyWebClassName(fieldRef, fieldClassName);
91
107
  useApplyWebClassName(inputRef, inputClassName);
92
108
  useApplyWebClassName(helperRef, error ? errorClassName : hintClassName);
93
109
 
94
110
  const isDisabled = editable === false;
111
+ const passwordToggleEnabled =
112
+ secureTextEntry === true && showPasswordToggle !== false && rightIcon == null;
113
+ const effectiveSecureTextEntry = passwordToggleEnabled
114
+ ? !passwordVisible
115
+ : secureTextEntry;
95
116
  const visualState = resolveInputVisualState({
96
117
  focused,
97
118
  error: Boolean(error),
@@ -112,6 +133,30 @@ export function Input({
112
133
 
113
134
  const helperMessage = error ?? hint;
114
135
 
136
+ function togglePasswordVisibility() {
137
+ if (!isDisabled) {
138
+ setPasswordVisible((visible) => !visible);
139
+ }
140
+ }
141
+
142
+ const trailingIcon = passwordToggleEnabled ? (
143
+ <Pressable
144
+ onPress={togglePasswordVisibility}
145
+ disabled={isDisabled}
146
+ accessibilityRole="button"
147
+ accessibilityLabel={passwordVisible ? "Hide password" : "Show password"}
148
+ hitSlop={8}
149
+ style={styles.iconPressable}
150
+ >
151
+ {renderInputIcon(
152
+ passwordVisible ? <EyeOffIcon /> : <EyeIcon />,
153
+ iconColor,
154
+ )}
155
+ </Pressable>
156
+ ) : rightIcon ? (
157
+ renderInputIcon(rightIcon, iconColor)
158
+ ) : null;
159
+
115
160
  return (
116
161
  <View ref={wrapperRef} style={[styles.wrapper, containerStyle]}>
117
162
  {label ? (
@@ -120,7 +165,10 @@ export function Input({
120
165
  </Label>
121
166
  ) : null}
122
167
  <View style={fieldStyles.outline}>
123
- <View style={[inputFieldMetrics.container, fieldStyles.container]}>
168
+ <View
169
+ ref={fieldRef}
170
+ style={[inputFieldMetrics.container, fieldStyles.container, fieldStyle]}
171
+ >
124
172
  {leftIcon ? (
125
173
  <View style={styles.iconSlot}>{renderInputIcon(leftIcon, iconColor)}</View>
126
174
  ) : null}
@@ -133,13 +181,14 @@ export function Input({
133
181
  ]}
134
182
  placeholderTextColor={fieldStyles.placeholder}
135
183
  editable={editable}
184
+ secureTextEntry={effectiveSecureTextEntry}
136
185
  onFocus={handleFocus}
137
186
  onBlur={handleBlur}
138
187
  accessibilityState={{ disabled: isDisabled }}
139
188
  {...props}
140
189
  />
141
- {rightIcon ? (
142
- <View style={styles.iconSlot}>{renderInputIcon(rightIcon, iconColor)}</View>
190
+ {trailingIcon ? (
191
+ <View style={styles.iconSlot}>{trailingIcon}</View>
143
192
  ) : null}
144
193
  </View>
145
194
  </View>
@@ -164,14 +213,20 @@ const styles = StyleSheet.create({
164
213
  justifyContent: "center",
165
214
  flexShrink: 0,
166
215
  },
216
+ iconPressable: {
217
+ width: INPUT_ICON_SIZE,
218
+ height: INPUT_ICON_SIZE,
219
+ alignItems: "center",
220
+ justifyContent: "center",
221
+ },
167
222
  error: {
168
223
  fontSize: 12,
169
- lineHeight: 12,
224
+ lineHeight: 16,
170
225
  color: colors.inputError,
171
226
  },
172
227
  hint: {
173
228
  fontSize: 12,
174
- lineHeight: 12,
229
+ lineHeight: 16,
175
230
  color: colors.stormGray300,
176
231
  },
177
232
  });
@@ -161,12 +161,12 @@ const styles = StyleSheet.create({
161
161
  },
162
162
  error: {
163
163
  fontSize: 12,
164
- lineHeight: 12,
164
+ lineHeight: 16,
165
165
  color: colors.inputError,
166
166
  },
167
167
  hint: {
168
168
  fontSize: 12,
169
- lineHeight: 12,
169
+ lineHeight: 16,
170
170
  color: colors.stormGray300,
171
171
  },
172
172
  });
@@ -1,48 +1,36 @@
1
- import { StyleSheet, View } from "react-native";
1
+ import Svg, { Path } from "react-native-svg";
2
+ import { EYE_OPEN_OUTLINE_PATH, EYE_OPEN_PUPIL_PATH } from "./eyeIconPaths";
3
+
4
+ export { EYE_OPEN_OUTLINE_PATH, EYE_OPEN_PUPIL_PATH } from "./eyeIconPaths";
2
5
 
3
6
  interface EyeIconProps {
4
7
  size?: number;
5
8
  color?: string;
9
+ strokeWidth?: number;
6
10
  }
7
11
 
8
- /** Minimal eye icon for input fields (no SVG dependency). */
9
- export function EyeIcon({ size = 20, color = "#c7cdd1" }: EyeIconProps) {
10
- const stroke = Math.max(1.5, size * 0.1);
11
- const eyeWidth = size * 0.78;
12
- const eyeHeight = size * 0.44;
13
-
12
+ /** Native open eye (password hidden). */
13
+ export function EyeIcon({
14
+ size = 20,
15
+ color = "#c7cdd1",
16
+ strokeWidth = 2,
17
+ }: EyeIconProps) {
14
18
  return (
15
- <View style={[styles.root, { width: size, height: size }]}>
16
- <View
17
- style={{
18
- position: "absolute",
19
- left: (size - eyeWidth) / 2,
20
- top: (size - eyeHeight) / 2,
21
- width: eyeWidth,
22
- height: eyeHeight,
23
- borderRadius: eyeHeight / 2,
24
- borderWidth: stroke,
25
- borderColor: color,
26
- }}
19
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill="none">
20
+ <Path
21
+ d={EYE_OPEN_OUTLINE_PATH}
22
+ stroke={color}
23
+ strokeWidth={strokeWidth}
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
27
26
  />
28
- <View
29
- style={{
30
- position: "absolute",
31
- left: size / 2 - size * 0.14,
32
- top: size / 2 - size * 0.14,
33
- width: size * 0.28,
34
- height: size * 0.28,
35
- borderRadius: size * 0.14,
36
- borderWidth: stroke,
37
- borderColor: color,
38
- }}
27
+ <Path
28
+ d={EYE_OPEN_PUPIL_PATH}
29
+ stroke={color}
30
+ strokeWidth={strokeWidth}
31
+ strokeLinecap="round"
32
+ strokeLinejoin="round"
39
33
  />
40
- </View>
34
+ </Svg>
41
35
  );
42
36
  }
43
-
44
- const styles = StyleSheet.create({
45
- root: {
46
- position: "relative",
47
- },
48
- });
@@ -0,0 +1,42 @@
1
+ import { EYE_OPEN_OUTLINE_PATH, EYE_OPEN_PUPIL_PATH } from "./eyeIconPaths";
2
+
3
+ interface EyeIconProps {
4
+ size?: number;
5
+ color?: string;
6
+ strokeWidth?: number;
7
+ }
8
+
9
+ /** Web — open eye (password hidden). */
10
+ export function EyeIcon({
11
+ size = 20,
12
+ color = "#c7cdd1",
13
+ strokeWidth = 2,
14
+ }: EyeIconProps) {
15
+ return (
16
+ <svg
17
+ width={size}
18
+ height={size}
19
+ viewBox="0 0 20 20"
20
+ fill="none"
21
+ xmlns="http://www.w3.org/2000/svg"
22
+ aria-hidden
23
+ >
24
+ <path
25
+ d={EYE_OPEN_OUTLINE_PATH}
26
+ stroke={color}
27
+ strokeWidth={strokeWidth}
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ />
31
+ <path
32
+ d={EYE_OPEN_PUPIL_PATH}
33
+ stroke={color}
34
+ strokeWidth={strokeWidth}
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ />
38
+ </svg>
39
+ );
40
+ }
41
+
42
+ export { EYE_OPEN_OUTLINE_PATH, EYE_OPEN_PUPIL_PATH } from "./eyeIconPaths";
@@ -0,0 +1,29 @@
1
+ import Svg, { Path } from "react-native-svg";
2
+ import { EYE_OFF_PATH } from "./eyeIconPaths";
3
+
4
+ export { EYE_OFF_PATH } from "./eyeIconPaths";
5
+
6
+ interface EyeOffIconProps {
7
+ size?: number;
8
+ color?: string;
9
+ strokeWidth?: number;
10
+ }
11
+
12
+ /** Native — slashed eye (password visible). */
13
+ export function EyeOffIcon({
14
+ size = 20,
15
+ color = "#c7cdd1",
16
+ strokeWidth = 2,
17
+ }: EyeOffIconProps) {
18
+ return (
19
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
20
+ <Path
21
+ d={EYE_OFF_PATH}
22
+ stroke={color}
23
+ strokeWidth={strokeWidth}
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
26
+ />
27
+ </Svg>
28
+ );
29
+ }
@@ -0,0 +1,35 @@
1
+ import { EYE_OFF_PATH } from "./eyeIconPaths";
2
+
3
+ interface EyeOffIconProps {
4
+ size?: number;
5
+ color?: string;
6
+ strokeWidth?: number;
7
+ }
8
+
9
+ /** Web — slashed eye (password visible). */
10
+ export function EyeOffIcon({
11
+ size = 20,
12
+ color = "#c7cdd1",
13
+ strokeWidth = 2,
14
+ }: EyeOffIconProps) {
15
+ return (
16
+ <svg
17
+ width={size}
18
+ height={size}
19
+ viewBox="0 0 24 24"
20
+ fill="none"
21
+ xmlns="http://www.w3.org/2000/svg"
22
+ aria-hidden
23
+ >
24
+ <path
25
+ d={EYE_OFF_PATH}
26
+ stroke={color}
27
+ strokeWidth={strokeWidth}
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ />
31
+ </svg>
32
+ );
33
+ }
34
+
35
+ export { EYE_OFF_PATH } from "./eyeIconPaths";
@@ -0,0 +1,8 @@
1
+ export const EYE_OPEN_OUTLINE_PATH =
2
+ "M1.71835 10.2898C1.6489 10.1027 1.6489 9.89691 1.71835 9.70981C2.39476 8.06969 3.54294 6.66735 5.01732 5.68056C6.4917 4.69378 8.22588 4.16699 10 4.16699C11.7741 4.16699 13.5083 4.69378 14.9827 5.68056C16.4571 6.66735 17.6053 8.06969 18.2817 9.70981C18.3511 9.89691 18.3511 10.1027 18.2817 10.2898C17.6053 11.9299 16.4571 13.3323 14.9827 14.3191C13.5083 15.3058 11.7741 15.8326 10 15.8326C8.22588 15.8326 6.4917 15.3058 5.01732 14.3191C3.54294 13.3323 2.39476 11.9299 1.71835 10.2898Z";
3
+
4
+ export const EYE_OPEN_PUPIL_PATH =
5
+ "M10 12.4998C11.3807 12.4998 12.5 11.3805 12.5 9.99981C12.5 8.6191 11.3807 7.49981 10 7.49981C8.6193 7.49981 7.50001 8.6191 7.50001 9.99981C7.50001 11.3805 8.6193 12.4998 10 12.4998Z";
6
+
7
+ export const EYE_OFF_PATH =
8
+ "M10.733 5.076C13.0624 4.7984 15.4186 5.29082 17.4419 6.47805C19.4651 7.66528 21.0442 9.48208 21.938 11.651C22.0213 11.8755 22.0213 12.1225 21.938 12.347C21.5705 13.238 21.0848 14.0755 20.494 14.837M14.084 14.158C13.5182 14.7045 12.7604 15.0069 11.9738 15C11.1872 14.9932 10.4348 14.6777 9.87854 14.1215C9.32232 13.5652 9.00681 12.8128 8.99998 12.0262C8.99314 11.2396 9.29553 10.4818 9.842 9.916M17.479 17.499C16.1525 18.2848 14.6725 18.776 13.1394 18.9394C11.6063 19.1028 10.056 18.9345 8.59363 18.4459C7.13131 17.9573 5.79119 17.1599 4.66421 16.1077C3.53723 15.0556 2.64975 13.7734 2.062 12.348C1.97866 12.1235 1.97866 11.8765 2.062 11.652C2.94863 9.50186 4.50867 7.69725 6.508 6.509M2 2L22 22";
@@ -37,18 +37,33 @@ interface FieldStyleSet {
37
37
  icon: string;
38
38
  }
39
39
 
40
- export function getInputFieldStyles(state: InputVisualState): FieldStyleSet {
41
- const outlineBase: ViewStyle = {
40
+ function createOutlineStyle(ringColor: string): ViewStyle {
41
+ return {
42
42
  borderRadius: radii.lg + INPUT_OUTLINE_WIDTH,
43
- padding: INPUT_OUTLINE_WIDTH,
43
+ borderWidth: INPUT_OUTLINE_WIDTH,
44
+ borderColor: ringColor,
45
+ padding: 0,
44
46
  backgroundColor: colors.transparent,
47
+ width: "100%",
48
+ alignSelf: "stretch",
49
+ ...(Platform.OS !== "web" ? { overflow: "hidden" as const } : null),
50
+ };
51
+ }
52
+
53
+ const defaultOutline = createOutlineStyle(colors.transparent);
54
+
55
+ export function getInputFieldStyles(state: InputVisualState): FieldStyleSet {
56
+ const containerBase: ViewStyle = {
57
+ borderRadius: radii.lg,
58
+ ...(Platform.OS !== "web" ? { overflow: "hidden" as const } : null),
45
59
  };
46
60
 
47
61
  switch (state) {
48
62
  case "disabled":
49
63
  return {
50
- outline: outlineBase,
64
+ outline: defaultOutline,
51
65
  container: {
66
+ ...containerBase,
52
67
  borderWidth: 1,
53
68
  borderColor: colors.stormGray50,
54
69
  backgroundColor: colors.stormGray50,
@@ -59,11 +74,9 @@ export function getInputFieldStyles(state: InputVisualState): FieldStyleSet {
59
74
  };
60
75
  case "error":
61
76
  return {
62
- outline: {
63
- ...outlineBase,
64
- backgroundColor: colors.inputOutlineError,
65
- },
77
+ outline: createOutlineStyle(colors.inputOutlineError),
66
78
  container: {
79
+ ...containerBase,
67
80
  borderWidth: 1,
68
81
  borderColor: colors.inputError,
69
82
  backgroundColor: colors.white,
@@ -74,11 +87,9 @@ export function getInputFieldStyles(state: InputVisualState): FieldStyleSet {
74
87
  };
75
88
  case "focused":
76
89
  return {
77
- outline: {
78
- ...outlineBase,
79
- backgroundColor: colors.inputOutlineFocus,
80
- },
90
+ outline: createOutlineStyle(colors.inputOutlineFocus),
81
91
  container: {
92
+ ...containerBase,
82
93
  borderWidth: 1,
83
94
  borderColor: colors.navy,
84
95
  backgroundColor: colors.white,
@@ -89,8 +100,9 @@ export function getInputFieldStyles(state: InputVisualState): FieldStyleSet {
89
100
  };
90
101
  default:
91
102
  return {
92
- outline: outlineBase,
103
+ outline: defaultOutline,
93
104
  container: {
105
+ ...containerBase,
94
106
  borderWidth: 1,
95
107
  borderColor: colors.stormGray50,
96
108
  backgroundColor: colors.white,
@@ -122,12 +134,15 @@ export const inputFieldMetrics = StyleSheet.create({
122
134
  fontFamily: fonts.sans,
123
135
  fontSize: fontSize.md,
124
136
  fontWeight: fontWeight.medium,
125
- lineHeight: fontSize.md,
137
+ lineHeight: Platform.OS === "web" ? fontSize.md : 20,
126
138
  paddingVertical: 0,
127
139
  paddingHorizontal: 0,
128
140
  margin: 0,
129
141
  borderWidth: 0,
130
142
  backgroundColor: colors.transparent,
143
+ ...(Platform.OS === "android"
144
+ ? { includeFontPadding: false }
145
+ : null),
131
146
  ...(Platform.OS === "web"
132
147
  ? ({
133
148
  height: "100%",
@@ -236,20 +236,20 @@ export const labelTypography = {
236
236
  letterSpacing: 0,
237
237
  } as const;
238
238
 
239
- /** Button label typography — matches Figma (SemiBold, 100% line-height). */
239
+ /** Button label typography — SemiBold; line-height slightly above 100% for RN clipping. */
240
240
  export const buttonTypography = {
241
241
  lg: {
242
242
  fontFamily: fonts.sans,
243
243
  fontSize: 14,
244
244
  fontWeight: fontWeight.semibold,
245
- lineHeight: 14,
245
+ lineHeight: 18,
246
246
  letterSpacing: 0,
247
247
  },
248
248
  sm: {
249
249
  fontFamily: fonts.sans,
250
250
  fontSize: 12,
251
251
  fontWeight: fontWeight.semibold,
252
- lineHeight: 12,
252
+ lineHeight: 16,
253
253
  letterSpacing: 0,
254
254
  },
255
255
  } as const;