@scripso-homepad/ui 0.3.6 → 0.3.7

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.
@@ -1,48 +1,111 @@
1
- import { useRef, type ComponentRef } from "react";
1
+ import { useRef, type ComponentRef, type ReactNode } from "react";
2
2
  import {
3
3
  StyleSheet,
4
4
  Text,
5
5
  TouchableOpacity,
6
+ View,
6
7
  type GestureResponderEvent,
7
8
  type StyleProp,
8
9
  type TextStyle,
9
10
  type ViewStyle,
10
11
  } from "react-native";
12
+ import { ArrowUpRightIcon } from "../icons/ArrowUpRightIcon";
13
+ import { colors, buttonTypography } from "../theme/tokens";
11
14
  import { useApplyWebClassName } from "../utils/useApplyWebClassName";
12
15
 
13
- export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost";
14
- export type ButtonSize = "small" | "medium" | "large";
16
+ export type ButtonVariant = "white" | "primary" | "green" | "gray";
17
+ export type ButtonSize = "lg" | "sm";
15
18
 
16
19
  export interface ButtonProps {
17
- title: string;
20
+ title?: string;
21
+ children?: ReactNode;
18
22
  onPress: (event: GestureResponderEvent) => void;
19
23
  disabled?: boolean;
20
- /** Visual style preset. */
21
24
  variant?: ButtonVariant;
22
- /** Size preset. */
23
25
  size?: ButtonSize;
24
- /** Additional container styles (works on web and native). */
26
+ showIcon?: boolean;
27
+ /** Custom icon inside the badge. Defaults to `ArrowUpRightIcon` when `showIcon` is true. */
28
+ icon?: ReactNode;
25
29
  style?: StyleProp<ViewStyle>;
26
- /** Additional label styles (works on web and native). */
27
30
  textStyle?: StyleProp<TextStyle>;
28
- /**
29
- * CSS class names for the container (web: applied to the same DOM node as default styles).
30
- * On native: ignored unless using NativeWind with cssInterop.
31
- */
32
31
  className?: string;
33
- /**
34
- * CSS class names for the label (web).
35
- * On native: ignored unless using NativeWind with cssInterop.
36
- */
37
32
  textClassName?: string;
38
33
  }
39
34
 
35
+ type VariantStyleSet = {
36
+ backgroundColor: string;
37
+ textColor: string;
38
+ iconBadgeBackgroundColor: string;
39
+ iconColor: string;
40
+ };
41
+
42
+ const variantConfig: Record<ButtonVariant, VariantStyleSet> = {
43
+ white: {
44
+ backgroundColor: colors.white,
45
+ textColor: colors.slateBlue,
46
+ iconBadgeBackgroundColor: colors.stormGray150,
47
+ iconColor: colors.navy,
48
+ },
49
+ primary: {
50
+ backgroundColor: colors.navy,
51
+ textColor: colors.stormGray0,
52
+ iconBadgeBackgroundColor: colors.white,
53
+ iconColor: colors.navy,
54
+ },
55
+ green: {
56
+ backgroundColor: colors.green,
57
+ textColor: colors.stormGray0,
58
+ iconBadgeBackgroundColor: colors.white,
59
+ iconColor: colors.green,
60
+ },
61
+ gray: {
62
+ backgroundColor: colors.stormGray50,
63
+ textColor: colors.slateBlue,
64
+ iconBadgeBackgroundColor: colors.stormGray150,
65
+ iconColor: colors.navy,
66
+ },
67
+ };
68
+
69
+ const sizeConfig = {
70
+ lg: {
71
+ borderRadius: 16,
72
+ paddingTop: 8,
73
+ paddingRight: 8,
74
+ paddingBottom: 8,
75
+ paddingLeft: 24,
76
+ paddingHorizontalCentered: 24,
77
+ minHeight: 52,
78
+ text: buttonTypography.lg,
79
+ iconContainerSize: 36,
80
+ iconContainerRadius: 12,
81
+ iconContainerPadding: 8,
82
+ iconSize: 20,
83
+ },
84
+ sm: {
85
+ borderRadius: 12,
86
+ paddingTop: 8,
87
+ paddingRight: 8,
88
+ paddingBottom: 8,
89
+ paddingLeft: 16,
90
+ paddingHorizontalCentered: 16,
91
+ minHeight: 48,
92
+ text: buttonTypography.sm,
93
+ iconContainerSize: 32,
94
+ iconContainerRadius: 8,
95
+ iconContainerPadding: 8,
96
+ iconSize: 16,
97
+ },
98
+ } as const;
99
+
40
100
  export function Button({
41
101
  title,
102
+ children,
42
103
  onPress,
43
104
  disabled = false,
44
105
  variant = "primary",
45
- size = "medium",
106
+ size = "lg",
107
+ showIcon = false,
108
+ icon,
46
109
  style,
47
110
  textStyle,
48
111
  className,
@@ -50,23 +113,40 @@ export function Button({
50
113
  }: ButtonProps) {
51
114
  const containerRef = useRef<ComponentRef<typeof TouchableOpacity>>(null);
52
115
  const textRef = useRef<ComponentRef<typeof Text>>(null);
116
+ const preset = variantConfig[variant];
117
+ const metrics = sizeConfig[size];
53
118
 
54
119
  useApplyWebClassName(containerRef, className);
55
120
  useApplyWebClassName(textRef, textClassName);
56
121
 
122
+ const label = typeof children !== "undefined" ? children : title;
123
+ const hasCustomChildren = typeof children !== "undefined";
124
+ const hasIcon = showIcon || icon != null;
125
+ const iconNode =
126
+ icon ?? <ArrowUpRightIcon size={metrics.iconSize} color={preset.iconColor} />;
127
+
57
128
  const containerStyle = [
58
129
  styles.base,
59
- variantStyles[variant],
60
- sizeStyles[size],
61
- disabled && disabledVariantStyles[variant],
130
+ {
131
+ borderRadius: metrics.borderRadius,
132
+ backgroundColor: preset.backgroundColor,
133
+ minHeight: metrics.minHeight,
134
+ paddingTop: metrics.paddingTop,
135
+ paddingBottom: metrics.paddingBottom,
136
+ paddingLeft: hasIcon ? metrics.paddingLeft : metrics.paddingHorizontalCentered,
137
+ paddingRight: hasIcon ? metrics.paddingRight : metrics.paddingHorizontalCentered,
138
+ },
139
+ hasIcon ? styles.withIcon : styles.centered,
140
+ disabled && styles.disabled,
62
141
  style,
63
142
  ];
64
143
 
65
144
  const labelStyle = [
66
- textBaseStyles.base,
67
- textVariantStyles[variant],
68
- textSizeStyles[size],
69
- disabled && textDisabledStyles[variant],
145
+ styles.label,
146
+ metrics.text,
147
+ { color: preset.textColor },
148
+ !hasIcon && styles.labelCentered,
149
+ hasIcon && styles.labelWithIcon,
70
150
  textStyle,
71
151
  ];
72
152
 
@@ -80,119 +160,61 @@ export function Button({
80
160
  accessibilityRole="button"
81
161
  accessibilityState={{ disabled }}
82
162
  >
83
- <Text ref={textRef} style={labelStyle}>
84
- {title}
85
- </Text>
163
+ {hasCustomChildren ? (
164
+ children
165
+ ) : (
166
+ <Text ref={textRef} style={labelStyle}>
167
+ {label}
168
+ </Text>
169
+ )}
170
+
171
+ {hasIcon ? (
172
+ <View
173
+ style={[
174
+ styles.iconContainer,
175
+ {
176
+ width: metrics.iconContainerSize,
177
+ height: metrics.iconContainerSize,
178
+ borderRadius: metrics.iconContainerRadius,
179
+ padding: metrics.iconContainerPadding,
180
+ backgroundColor: preset.iconBadgeBackgroundColor,
181
+ },
182
+ ]}
183
+ >
184
+ {iconNode}
185
+ </View>
186
+ ) : null}
86
187
  </TouchableOpacity>
87
188
  );
88
189
  }
89
190
 
90
191
  const styles = StyleSheet.create({
91
192
  base: {
193
+ flexDirection: "row",
92
194
  alignItems: "center",
93
- justifyContent: "center",
94
- borderRadius: 8,
95
195
  borderWidth: 0,
96
196
  },
97
- });
98
-
99
- const variantStyles = StyleSheet.create({
100
- primary: {
101
- backgroundColor: "#2563eb",
102
- },
103
- secondary: {
104
- backgroundColor: "#4b5563",
105
- },
106
- outline: {
107
- backgroundColor: "transparent",
108
- borderWidth: 1,
109
- borderColor: "#2563eb",
110
- },
111
- ghost: {
112
- backgroundColor: "transparent",
113
- },
114
- });
115
-
116
- const sizeStyles = StyleSheet.create({
117
- small: {
118
- paddingVertical: 8,
119
- paddingHorizontal: 16,
120
- minWidth: 96,
121
- },
122
- medium: {
123
- paddingVertical: 12,
124
- paddingHorizontal: 24,
125
- minWidth: 120,
126
- },
127
- large: {
128
- paddingVertical: 16,
129
- paddingHorizontal: 32,
130
- minWidth: 160,
131
- },
132
- });
133
-
134
- const disabledVariantStyles = StyleSheet.create({
135
- primary: {
136
- backgroundColor: "#93c5fd",
137
- opacity: 0.7,
138
- },
139
- secondary: {
140
- backgroundColor: "#9ca3af",
141
- opacity: 0.7,
142
- },
143
- outline: {
144
- borderColor: "#93c5fd",
145
- opacity: 0.7,
146
- },
147
- ghost: {
148
- opacity: 0.5,
149
- },
150
- });
151
-
152
- const textBaseStyles = StyleSheet.create({
153
- base: {
154
- fontWeight: "600",
155
- },
156
- });
157
-
158
- const textVariantStyles = StyleSheet.create({
159
- primary: {
160
- color: "#ffffff",
161
- },
162
- secondary: {
163
- color: "#ffffff",
164
- },
165
- outline: {
166
- color: "#2563eb",
197
+ centered: {
198
+ justifyContent: "center",
167
199
  },
168
- ghost: {
169
- color: "#2563eb",
200
+ withIcon: {
201
+ justifyContent: "space-between",
170
202
  },
171
- });
172
-
173
- const textSizeStyles = StyleSheet.create({
174
- small: {
175
- fontSize: 14,
203
+ disabled: {
204
+ opacity: 0.6,
176
205
  },
177
- medium: {
178
- fontSize: 16,
206
+ label: {},
207
+ labelCentered: {
208
+ textAlign: "center",
179
209
  },
180
- large: {
181
- fontSize: 18,
210
+ labelWithIcon: {
211
+ flex: 1,
212
+ flexShrink: 1,
213
+ marginRight: 8,
182
214
  },
183
- });
184
-
185
- const textDisabledStyles = StyleSheet.create({
186
- primary: {
187
- color: "#e5e7eb",
188
- },
189
- secondary: {
190
- color: "#f3f4f6",
191
- },
192
- outline: {
193
- color: "#93c5fd",
194
- },
195
- ghost: {
196
- color: "#93c5fd",
215
+ iconContainer: {
216
+ alignItems: "center",
217
+ justifyContent: "center",
218
+ flexShrink: 0,
197
219
  },
198
220
  });
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { View, StyleSheet } from "react-native";
4
+ import { CountryCodeSelector } from "./CountryCodeSelector";
5
+
6
+ const meta = {
7
+ title: "Components/CountryCodeSelector",
8
+ component: CountryCodeSelector,
9
+ } satisfies Meta<typeof CountryCodeSelector>;
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof meta>;
13
+
14
+ export const Default: Story = {
15
+ render: () => (
16
+ <View style={styles.wrapper}>
17
+ <CountryCodeSelector />
18
+ </View>
19
+ ),
20
+ };
21
+
22
+ export const Controlled: Story = {
23
+ render: function ControlledCountryStory() {
24
+ const [code, setCode] = useState("AM");
25
+ return (
26
+ <View style={styles.wrapper}>
27
+ <CountryCodeSelector value={code} onValueChange={setCode} />
28
+ </View>
29
+ );
30
+ },
31
+ };
32
+
33
+ export const Disabled: Story = {
34
+ render: () => (
35
+ <View style={styles.wrapper}>
36
+ <CountryCodeSelector value="AM" disabled />
37
+ </View>
38
+ ),
39
+ };
40
+
41
+ /** Opens upward when near the bottom of the viewport. */
42
+ export const OpenNearBottom: Story = {
43
+ render: () => (
44
+ <View style={styles.bottomWrapper}>
45
+ <CountryCodeSelector />
46
+ </View>
47
+ ),
48
+ };
49
+
50
+ const styles = StyleSheet.create({
51
+ wrapper: {
52
+ alignItems: "flex-start",
53
+ },
54
+ bottomWrapper: {
55
+ flex: 1,
56
+ minHeight: 500,
57
+ justifyContent: "flex-end",
58
+ alignItems: "flex-start",
59
+ paddingBottom: 16,
60
+ },
61
+ });
@@ -0,0 +1,273 @@
1
+ import { useRef, useState, useLayoutEffect, type ComponentRef } from "react";
2
+ import {
3
+ Dimensions,
4
+ Modal,
5
+ Platform,
6
+ Pressable,
7
+ ScrollView,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ type StyleProp,
12
+ type ViewStyle,
13
+ } from "react-native";
14
+ import {
15
+ countries,
16
+ defaultCountry,
17
+ findCountry,
18
+ type Country,
19
+ } from "../data/countries";
20
+ import { ChevronDownIcon } from "../icons/ChevronDownIcon";
21
+ import { colors, fontSize, fontWeight, radii, spacing, fonts } from "../theme/tokens";
22
+ import { countryCodeToFlagEmoji } from "../utils/countryFlag";
23
+ import {
24
+ COUNTRY_DROPDOWN_SCROLL_CLASS,
25
+ ensureCountryDropdownScrollStyles,
26
+ } from "../utils/countryDropdownScrollStyles";
27
+ import { useApplyWebClassName } from "../utils/useApplyWebClassName";
28
+
29
+ const DROPDOWN_GAP = 10;
30
+ const DROPDOWN_HEIGHT = 186;
31
+ const DROPDOWN_PADDING = 8;
32
+ const OPTION_GAP = 10;
33
+ const OPTION_HEIGHT = 35;
34
+ const TRIGGER_HEIGHT = 52;
35
+ const CHEVRON_SIZE = 16;
36
+
37
+ export interface CountryCodeSelectorProps {
38
+ value?: string;
39
+ onValueChange?: (countryCode: string) => void;
40
+ /** Country list (defaults to built-in list). */
41
+ options?: Country[];
42
+ disabled?: boolean;
43
+ style?: StyleProp<ViewStyle>;
44
+ className?: string;
45
+ }
46
+
47
+ type AnchorLayout = {
48
+ x: number;
49
+ y: number;
50
+ width: number;
51
+ height: number;
52
+ };
53
+
54
+ function resolveDropdownTop(anchor: AnchorLayout): number {
55
+ const windowHeight = Dimensions.get("window").height;
56
+ const topBelow = anchor.y + anchor.height + DROPDOWN_GAP;
57
+ const topAbove = anchor.y - DROPDOWN_GAP - DROPDOWN_HEIGHT;
58
+ const fitsBelow = topBelow + DROPDOWN_HEIGHT <= windowHeight;
59
+
60
+ if (fitsBelow) return topBelow;
61
+ if (topAbove >= 0) return topAbove;
62
+
63
+ return Math.max(0, Math.min(topBelow, windowHeight - DROPDOWN_HEIGHT));
64
+ }
65
+
66
+ export function CountryCodeSelector({
67
+ value = defaultCountry.code,
68
+ onValueChange,
69
+ options = countries,
70
+ disabled = false,
71
+ style,
72
+ className,
73
+ }: CountryCodeSelectorProps) {
74
+ const wrapperRef = useRef<ComponentRef<typeof View>>(null);
75
+ const triggerRef = useRef<ComponentRef<typeof Pressable>>(null);
76
+ const scrollRef = useRef<ComponentRef<typeof ScrollView>>(null);
77
+ const [open, setOpen] = useState(false);
78
+ const [anchor, setAnchor] = useState<AnchorLayout | null>(null);
79
+
80
+ useApplyWebClassName(wrapperRef, className);
81
+ useApplyWebClassName(
82
+ scrollRef,
83
+ COUNTRY_DROPDOWN_SCROLL_CLASS,
84
+ open && Boolean(anchor),
85
+ );
86
+
87
+ useLayoutEffect(() => {
88
+ ensureCountryDropdownScrollStyles();
89
+ }, []);
90
+
91
+ const selected = findCountry(value);
92
+ const textColor = disabled ? colors.stormGray200 : colors.slateBlue;
93
+
94
+ function closeDropdown() {
95
+ setOpen(false);
96
+ setAnchor(null);
97
+ }
98
+
99
+ function handleOpen() {
100
+ if (disabled) return;
101
+
102
+ triggerRef.current?.measureInWindow((x, y, width, height) => {
103
+ setAnchor({ x, y, width, height });
104
+ setOpen(true);
105
+ });
106
+ }
107
+
108
+ function handleSelect(code: string) {
109
+ onValueChange?.(code);
110
+ closeDropdown();
111
+ }
112
+
113
+ const dropdownTop = anchor ? resolveDropdownTop(anchor) : 0;
114
+
115
+ const optionList = options.map((item, index) => {
116
+ const isSelected = item.code === selected.code;
117
+ const isLast = index === options.length - 1;
118
+ return (
119
+ <Pressable
120
+ key={item.code}
121
+ accessibilityRole="button"
122
+ onPress={() => handleSelect(item.code)}
123
+ style={[
124
+ styles.option,
125
+ isSelected && styles.optionSelected,
126
+ !isLast && styles.optionSpacing,
127
+ ]}
128
+ >
129
+ <Text style={styles.flag}>{countryCodeToFlagEmoji(item.code)}</Text>
130
+ <Text style={styles.optionDialCode}>{item.dialCode}</Text>
131
+ </Pressable>
132
+ );
133
+ });
134
+
135
+ const dropdownList = (
136
+ <ScrollView
137
+ ref={scrollRef}
138
+ style={styles.dropdownScroll}
139
+ contentContainerStyle={styles.dropdownContent}
140
+ keyboardShouldPersistTaps="handled"
141
+ showsVerticalScrollIndicator
142
+ bounces={false}
143
+ nestedScrollEnabled
144
+ >
145
+ {optionList}
146
+ </ScrollView>
147
+ );
148
+
149
+ return (
150
+ <View ref={wrapperRef} style={[styles.root, style]}>
151
+ <Pressable
152
+ ref={triggerRef}
153
+ accessibilityRole="button"
154
+ disabled={disabled}
155
+ onPress={handleOpen}
156
+ style={[
157
+ styles.trigger,
158
+ disabled ? styles.triggerDisabled : styles.triggerEnabled,
159
+ Platform.OS === "web" ? styles.triggerWeb : null,
160
+ ]}
161
+ >
162
+ <Text style={styles.flag}>{countryCodeToFlagEmoji(selected.code)}</Text>
163
+ <Text style={[styles.dialCode, { color: textColor }]}>{selected.dialCode}</Text>
164
+ <ChevronDownIcon size={CHEVRON_SIZE} color={colors.stormGray100} />
165
+ </Pressable>
166
+
167
+ <Modal
168
+ visible={open}
169
+ transparent
170
+ animationType="fade"
171
+ onRequestClose={closeDropdown}
172
+ >
173
+ <Pressable style={styles.overlay} onPress={closeDropdown}>
174
+ {anchor ? (
175
+ <View
176
+ style={[
177
+ styles.dropdown,
178
+ {
179
+ top: dropdownTop,
180
+ left: anchor.x,
181
+ width: anchor.width,
182
+ },
183
+ ]}
184
+ >
185
+ {dropdownList}
186
+ </View>
187
+ ) : null}
188
+ </Pressable>
189
+ </Modal>
190
+ </View>
191
+ );
192
+ }
193
+
194
+ const styles = StyleSheet.create({
195
+ root: {
196
+ alignSelf: "flex-start",
197
+ flexShrink: 0,
198
+ },
199
+ trigger: {
200
+ flexDirection: "row",
201
+ alignItems: "center",
202
+ alignSelf: "flex-start",
203
+ flexGrow: 0,
204
+ flexShrink: 0,
205
+ height: TRIGGER_HEIGHT,
206
+ borderRadius: radii.lg,
207
+ borderWidth: 1,
208
+ padding: spacing.lg,
209
+ gap: spacing.sm,
210
+ },
211
+ triggerEnabled: {
212
+ borderColor: colors.stormGray50,
213
+ backgroundColor: colors.white,
214
+ },
215
+ triggerDisabled: {
216
+ borderColor: colors.stormGray50,
217
+ backgroundColor: colors.stormGray50,
218
+ },
219
+ triggerWeb: {
220
+ width: "max-content" as ViewStyle["width"],
221
+ },
222
+ flag: {
223
+ fontSize: 18,
224
+ lineHeight: 22,
225
+ },
226
+ dialCode: {
227
+ fontSize: fontSize.md,
228
+ fontWeight: fontWeight.medium,
229
+ },
230
+ overlay: {
231
+ flex: 1,
232
+ backgroundColor: "transparent",
233
+ },
234
+ dropdown: {
235
+ position: "absolute",
236
+ height: DROPDOWN_HEIGHT,
237
+ borderRadius: radii.lg,
238
+ borderWidth: 1,
239
+ borderColor: colors.stormGray50,
240
+ backgroundColor: colors.white,
241
+ padding: DROPDOWN_PADDING,
242
+ overflow: "hidden",
243
+ },
244
+ dropdownScroll: {
245
+ flex: 1,
246
+ },
247
+ dropdownContent: {
248
+ flexGrow: 1,
249
+ },
250
+ option: {
251
+ flexDirection: "row",
252
+ alignItems: "center",
253
+ height: OPTION_HEIGHT,
254
+ borderRadius: radii.sm,
255
+ padding: spacing.sm,
256
+ gap: spacing.sm,
257
+ },
258
+ optionSpacing: {
259
+ marginBottom: OPTION_GAP,
260
+ },
261
+ optionSelected: {
262
+ backgroundColor: colors.countrySelectorSelectedBg,
263
+ },
264
+ optionDialCode: {
265
+ fontFamily: fonts.sans,
266
+ fontSize: fontSize.md,
267
+ fontWeight: fontWeight.regular,
268
+ lineHeight: fontSize.md,
269
+ letterSpacing: 0,
270
+ textAlign: "center",
271
+ color: colors.slateBlue,
272
+ },
273
+ });