@ecohouse/ui 0.1.0

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.
@@ -0,0 +1,135 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { fn } from "@storybook/test";
3
+ import { View, StyleSheet, Text } from "react-native";
4
+ import { Button } from "./Button";
5
+
6
+ const meta = {
7
+ title: "Components/Button",
8
+ component: Button,
9
+ args: {
10
+ title: "Button",
11
+ onPress: fn(),
12
+ disabled: false,
13
+ variant: "primary",
14
+ size: "lg",
15
+ showIcon: false,
16
+ },
17
+ argTypes: {
18
+ onPress: { action: "pressed" },
19
+ variant: {
20
+ control: "select",
21
+ options: ["white", "primary", "green", "gray"],
22
+ },
23
+ size: {
24
+ control: "select",
25
+ options: ["lg", "sm"],
26
+ },
27
+ },
28
+ } satisfies Meta<typeof Button>;
29
+
30
+ export default meta;
31
+ type Story = StoryObj<typeof meta>;
32
+
33
+ export const Default: Story = {};
34
+
35
+ export const WithIcon: Story = {
36
+ args: { showIcon: true, className: "w-full" },
37
+ };
38
+
39
+ export const WithCustomIcon: Story = {
40
+ args: {
41
+ title: "Custom icon",
42
+ showIcon: true,
43
+ icon: <Text style={{ fontSize: 16 }}>★</Text>,
44
+ className: "w-full",
45
+ },
46
+ };
47
+
48
+ export const WithClassName: Story = {
49
+ args: {
50
+ showIcon: true,
51
+ className: "w-full max-w-md",
52
+ },
53
+ };
54
+
55
+ export const AllVariants: Story = {
56
+ render: () => (
57
+ <View style={storyStyles.column}>
58
+ <Button title="Button" variant="gray" onPress={fn()} />
59
+ <Button title="Button" variant="white" onPress={fn()} />
60
+ <Button title="Button" variant="primary" onPress={fn()} />
61
+ <Button title="Button" variant="green" onPress={fn()} />
62
+ </View>
63
+ ),
64
+ };
65
+
66
+ export const AllVariantsWithIcon: Story = {
67
+ render: () => (
68
+ <View style={storyStyles.column}>
69
+ <Button title="Button" variant="gray" showIcon onPress={fn()} />
70
+ <Button title="Button" variant="white" showIcon onPress={fn()} />
71
+ <Button title="Button" variant="primary" showIcon onPress={fn()} />
72
+ <Button title="Button" variant="green" showIcon onPress={fn()} />
73
+ </View>
74
+ ),
75
+ };
76
+
77
+ export const Disabled: Story = {
78
+ render: () => (
79
+ <View style={storyStyles.grid}>
80
+ <View style={storyStyles.column}>
81
+ <Button title="Button" variant="gray" onPress={fn()} />
82
+ <Button title="Button" variant="white" onPress={fn()} />
83
+ <Button title="Button" variant="primary" onPress={fn()} />
84
+ <Button title="Button" variant="green" onPress={fn()} />
85
+ </View>
86
+ <View style={storyStyles.column}>
87
+ <Button title="Button" variant="gray" disabled onPress={fn()} />
88
+ <Button title="Button" variant="white" disabled onPress={fn()} />
89
+ <Button title="Button" variant="primary" disabled onPress={fn()} />
90
+ <Button title="Button" variant="green" disabled onPress={fn()} />
91
+ </View>
92
+ </View>
93
+ ),
94
+ };
95
+
96
+ export const DisabledWithIcon: Story = {
97
+ render: () => (
98
+ <View style={storyStyles.grid}>
99
+ <View style={storyStyles.column}>
100
+ <Button title="Button" variant="gray" showIcon onPress={fn()} />
101
+ <Button title="Button" variant="white" showIcon onPress={fn()} />
102
+ <Button title="Button" variant="primary" showIcon onPress={fn()} />
103
+ <Button title="Button" variant="green" showIcon onPress={fn()} />
104
+ </View>
105
+ <View style={storyStyles.column}>
106
+ <Button title="Button" variant="gray" showIcon disabled onPress={fn()} />
107
+ <Button title="Button" variant="white" showIcon disabled onPress={fn()} />
108
+ <Button title="Button" variant="primary" showIcon disabled onPress={fn()} />
109
+ <Button title="Button" variant="green" showIcon disabled onPress={fn()} />
110
+ </View>
111
+ </View>
112
+ ),
113
+ };
114
+
115
+ export const Sizes: Story = {
116
+ render: () => (
117
+ <View style={storyStyles.column}>
118
+ <Button title="Large" size="lg" showIcon onPress={fn()} />
119
+ <Button title="Small" size="sm" showIcon onPress={fn()} />
120
+ <Button title="Large" size="lg" onPress={fn()} />
121
+ <Button title="Small" size="sm" onPress={fn()} />
122
+ </View>
123
+ ),
124
+ };
125
+
126
+ const storyStyles = StyleSheet.create({
127
+ column: {
128
+ gap: 12,
129
+ width: 320,
130
+ },
131
+ grid: {
132
+ flexDirection: "row",
133
+ gap: 24,
134
+ },
135
+ });
@@ -0,0 +1,232 @@
1
+ import { useRef, type ComponentRef, type ReactNode } from "react";
2
+ import {
3
+ Platform,
4
+ StyleSheet,
5
+ Text,
6
+ TouchableOpacity,
7
+ View,
8
+ type GestureResponderEvent,
9
+ type StyleProp,
10
+ type TextStyle,
11
+ type ViewStyle,
12
+ } from "react-native";
13
+ import { ArrowUpRightIcon } from "../icons/ArrowUpRightIcon";
14
+ import { colors, buttonTypography } from "../theme/tokens";
15
+ import { useApplyWebClassName } from "../utils/useApplyWebClassName";
16
+
17
+ export type ButtonVariant = "white" | "primary" | "green" | "gray";
18
+ export type ButtonSize = "lg" | "sm";
19
+
20
+ export interface ButtonProps {
21
+ title?: string;
22
+ children?: ReactNode;
23
+ onPress: (event: GestureResponderEvent) => void;
24
+ disabled?: boolean;
25
+ variant?: ButtonVariant;
26
+ size?: ButtonSize;
27
+ showIcon?: boolean;
28
+ /** Custom icon inside the badge. Defaults to `ArrowUpRightIcon` when `showIcon` is true. */
29
+ icon?: ReactNode;
30
+ style?: StyleProp<ViewStyle>;
31
+ textStyle?: StyleProp<TextStyle>;
32
+ className?: string;
33
+ textClassName?: string;
34
+ }
35
+
36
+ type VariantStyleSet = {
37
+ backgroundColor: string;
38
+ textColor: string;
39
+ iconBadgeBackgroundColor: string;
40
+ iconColor: string;
41
+ };
42
+
43
+ const variantConfig: Record<ButtonVariant, VariantStyleSet> = {
44
+ white: {
45
+ backgroundColor: colors.white,
46
+ textColor: colors.slateBlue,
47
+ iconBadgeBackgroundColor: colors.stormGray150,
48
+ iconColor: colors.navy,
49
+ },
50
+ primary: {
51
+ backgroundColor: colors.navy,
52
+ textColor: colors.stormGray0,
53
+ iconBadgeBackgroundColor: colors.white,
54
+ iconColor: colors.navy,
55
+ },
56
+ green: {
57
+ backgroundColor: colors.green,
58
+ textColor: colors.stormGray0,
59
+ iconBadgeBackgroundColor: colors.white,
60
+ iconColor: colors.green,
61
+ },
62
+ gray: {
63
+ backgroundColor: colors.stormGray50,
64
+ textColor: colors.slateBlue,
65
+ iconBadgeBackgroundColor: colors.stormGray150,
66
+ iconColor: colors.navy,
67
+ },
68
+ };
69
+
70
+ const sizeConfig = {
71
+ lg: {
72
+ /** Matches Tailwind `h-13` so size stays stable before web classNames attach. */
73
+ minHeight: 52,
74
+ borderRadius: 16,
75
+ paddingTop: 8,
76
+ paddingRight: 8,
77
+ paddingBottom: 8,
78
+ paddingLeft: 24,
79
+ paddingHorizontalCentered: 24,
80
+ text: buttonTypography.lg,
81
+ iconContainerSize: 36,
82
+ iconContainerRadius: 12,
83
+ iconContainerPadding: 8,
84
+ iconSize: 20,
85
+ },
86
+ sm: {
87
+ minHeight: 40,
88
+ borderRadius: 12,
89
+ paddingTop: 8,
90
+ paddingRight: 8,
91
+ paddingBottom: 8,
92
+ paddingLeft: 16,
93
+ paddingHorizontalCentered: 16,
94
+ text: buttonTypography.sm,
95
+ iconContainerSize: 32,
96
+ iconContainerRadius: 8,
97
+ iconContainerPadding: 8,
98
+ iconSize: 16,
99
+ },
100
+ } as const;
101
+
102
+ export function Button({
103
+ title,
104
+ children,
105
+ onPress,
106
+ disabled = false,
107
+ variant = "primary",
108
+ size = "lg",
109
+ showIcon = false,
110
+ icon,
111
+ style,
112
+ textStyle,
113
+ className,
114
+ textClassName,
115
+ }: ButtonProps) {
116
+ const containerRef = useRef<ComponentRef<typeof TouchableOpacity>>(null);
117
+ const textRef = useRef<ComponentRef<typeof Text>>(null);
118
+ const preset = variantConfig[variant];
119
+ const metrics = sizeConfig[size];
120
+
121
+ const label = typeof children !== "undefined" ? children : title;
122
+ const hasCustomChildren = typeof children !== "undefined";
123
+ const customIcon = icon != null && typeof icon !== "boolean" ? icon : undefined;
124
+ const hasIcon = showIcon || icon === true || customIcon != null;
125
+ const resolvedContainerClassName = [Platform.OS === "web" ? "max-md:px-4!" : "", className]
126
+ .filter(Boolean)
127
+ .join(" ");
128
+
129
+ useApplyWebClassName(containerRef, resolvedContainerClassName || undefined);
130
+ useApplyWebClassName(textRef, textClassName);
131
+
132
+ const iconNode = customIcon ?? (
133
+ <ArrowUpRightIcon size={metrics.iconSize} color={preset.iconColor} />
134
+ );
135
+
136
+ const containerStyle = [
137
+ styles.base,
138
+ {
139
+ minHeight: metrics.minHeight,
140
+ borderRadius: metrics.borderRadius,
141
+ backgroundColor: preset.backgroundColor,
142
+ paddingTop: metrics.paddingTop,
143
+ paddingBottom: metrics.paddingBottom,
144
+ paddingLeft: hasIcon ? metrics.paddingLeft : metrics.paddingHorizontalCentered,
145
+ paddingRight: hasIcon ? metrics.paddingRight : metrics.paddingHorizontalCentered,
146
+ },
147
+ hasIcon ? styles.withIcon : styles.centered,
148
+ disabled && styles.disabled,
149
+ style,
150
+ ];
151
+
152
+ const labelStyle = [
153
+ styles.label,
154
+ metrics.text,
155
+ { color: preset.textColor },
156
+ Platform.OS === "android" ? styles.labelAndroid : null,
157
+ !hasIcon && styles.labelCentered,
158
+ hasIcon && styles.labelWithIcon,
159
+ textStyle,
160
+ ];
161
+
162
+ return (
163
+ <TouchableOpacity
164
+ ref={containerRef}
165
+ style={containerStyle}
166
+ onPress={onPress}
167
+ disabled={disabled}
168
+ activeOpacity={0.7}
169
+ accessibilityRole="button"
170
+ accessibilityState={{ disabled }}
171
+ >
172
+ {hasCustomChildren ? (
173
+ children
174
+ ) : (
175
+ <Text ref={textRef} style={labelStyle}>
176
+ {label}
177
+ </Text>
178
+ )}
179
+
180
+ {hasIcon ? (
181
+ <View
182
+ style={[
183
+ styles.iconContainer,
184
+ {
185
+ width: metrics.iconContainerSize,
186
+ height: metrics.iconContainerSize,
187
+ borderRadius: metrics.iconContainerRadius,
188
+ padding: metrics.iconContainerPadding,
189
+ backgroundColor: preset.iconBadgeBackgroundColor,
190
+ },
191
+ ]}
192
+ >
193
+ {iconNode}
194
+ </View>
195
+ ) : null}
196
+ </TouchableOpacity>
197
+ );
198
+ }
199
+
200
+ const styles = StyleSheet.create({
201
+ base: {
202
+ flexDirection: "row",
203
+ alignItems: "center",
204
+ borderWidth: 0,
205
+ },
206
+ centered: {
207
+ justifyContent: "center",
208
+ },
209
+ withIcon: {
210
+ justifyContent: "space-between",
211
+ },
212
+ disabled: {
213
+ opacity: 0.6,
214
+ },
215
+ label: {},
216
+ labelAndroid: {
217
+ includeFontPadding: false,
218
+ },
219
+ labelCentered: {
220
+ textAlign: "center",
221
+ },
222
+ labelWithIcon: {
223
+ flex: 1,
224
+ flexShrink: 1,
225
+ marginRight: 8,
226
+ },
227
+ iconContainer: {
228
+ alignItems: "center",
229
+ justifyContent: "center",
230
+ flexShrink: 0,
231
+ },
232
+ });
package/src/css.d.ts ADDED
@@ -0,0 +1 @@
1
+ declare module "*.css";
@@ -0,0 +1,29 @@
1
+ import Svg, { Path } from "react-native-svg";
2
+ import { ARROW_UP_RIGHT_PATH } from "./arrowUpRightPath";
3
+
4
+ export { ARROW_UP_RIGHT_PATH } from "./arrowUpRightPath";
5
+
6
+ interface ArrowUpRightIconProps {
7
+ size?: number;
8
+ color?: string;
9
+ strokeWidth?: number;
10
+ }
11
+
12
+ /** Native — react-native-svg (peer dependency). */
13
+ export function ArrowUpRightIcon({
14
+ size = 20,
15
+ color = "#082640",
16
+ strokeWidth = 2,
17
+ }: ArrowUpRightIconProps) {
18
+ return (
19
+ <Svg width={size} height={size} viewBox="0 0 20 20" fill="none">
20
+ <Path
21
+ d={ARROW_UP_RIGHT_PATH}
22
+ stroke={color}
23
+ strokeWidth={strokeWidth}
24
+ strokeLinecap="round"
25
+ strokeLinejoin="round"
26
+ />
27
+ </Svg>
28
+ );
29
+ }
@@ -0,0 +1,35 @@
1
+ import { ARROW_UP_RIGHT_PATH } from "./arrowUpRightPath";
2
+
3
+ interface ArrowUpRightIconProps {
4
+ size?: number;
5
+ color?: string;
6
+ strokeWidth?: number;
7
+ }
8
+
9
+ /** Web / Storybook — plain SVG (no react-native-svg). */
10
+ export function ArrowUpRightIcon({
11
+ size = 20,
12
+ color = "#082640",
13
+ strokeWidth = 2,
14
+ }: ArrowUpRightIconProps) {
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={ARROW_UP_RIGHT_PATH}
26
+ stroke={color}
27
+ strokeWidth={strokeWidth}
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ />
31
+ </svg>
32
+ );
33
+ }
34
+
35
+ export { ARROW_UP_RIGHT_PATH } from "./arrowUpRightPath";
@@ -0,0 +1 @@
1
+ export const ARROW_UP_RIGHT_PATH = "M14.1667 14.1668V5.8335H5.83333M14.1667 5.8335L5.83333 14.1668";
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { Button } from "./components/Button";
2
+ export type { ButtonProps, ButtonSize, ButtonVariant } from "./components/Button";
3
+
4
+ export { ArrowUpRightIcon } from "./icons/ArrowUpRightIcon";
5
+
6
+ export {
7
+ colors,
8
+ brand,
9
+ emeraldGreen,
10
+ navy,
11
+ rubyRed,
12
+ stormGray,
13
+ buttonTypography,
14
+ labelTypography,
15
+ fonts,
16
+ fontSize,
17
+ fontWeight,
18
+ radii,
19
+ shadows,
20
+ spacing,
21
+ typographyScale,
22
+ } from "./theme/tokens";
23
+ export type { EmeraldGreenStep, NavyStep, RubyRedStep, StormGrayStep } from "./theme/tokens";
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buttonTypography, colors } from "../theme/tokens";
3
+
4
+ describe("design tokens", () => {
5
+ it("exposes navy and button typography used by Button", () => {
6
+ expect(colors.navy).toMatch(/^#/);
7
+ expect(buttonTypography.lg.fontSize).toBe(14);
8
+ expect(buttonTypography.sm.fontSize).toBe(12);
9
+ });
10
+ });