@factorialco/f0-react-native 0.34.0 → 0.35.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.
- package/README.md +7 -5
- package/lib/module/components/Button/index.js +1 -1
- package/lib/module/components/Button/index.js.map +1 -1
- package/lib/module/components/F0Button/F0Button.js +2 -0
- package/lib/module/components/F0Button/F0Button.js.map +1 -0
- package/lib/module/components/F0Button/F0Button.md +163 -0
- package/lib/module/components/F0Button/F0Button.styles.js +2 -0
- package/lib/module/components/F0Button/F0Button.styles.js.map +1 -0
- package/lib/module/components/F0Button/F0Button.types.js +2 -0
- package/lib/module/components/F0Button/F0Button.types.js.map +1 -0
- package/lib/module/components/F0Button/index.js +2 -0
- package/lib/module/components/F0Button/index.js.map +1 -0
- package/lib/module/components/Icon/index.js.map +1 -1
- package/lib/module/components/exports.js +1 -1
- package/lib/module/components/exports.js.map +1 -1
- package/lib/typescript/components/Button/index.d.ts +18 -0
- package/lib/typescript/components/Button/index.d.ts.map +1 -1
- package/lib/typescript/components/F0Button/F0Button.d.ts +6 -0
- package/lib/typescript/components/F0Button/F0Button.d.ts.map +1 -0
- package/lib/typescript/components/F0Button/F0Button.styles.d.ts +161 -0
- package/lib/typescript/components/F0Button/F0Button.styles.d.ts.map +1 -0
- package/lib/typescript/components/F0Button/F0Button.types.d.ts +47 -0
- package/lib/typescript/components/F0Button/F0Button.types.d.ts.map +1 -0
- package/lib/typescript/components/F0Button/index.d.ts +4 -0
- package/lib/typescript/components/F0Button/index.d.ts.map +1 -0
- package/lib/typescript/components/Icon/index.d.ts +5 -0
- package/lib/typescript/components/Icon/index.d.ts.map +1 -1
- package/lib/typescript/components/exports.d.ts +1 -0
- package/lib/typescript/components/exports.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Button/__snapshots__/index.spec.tsx.snap +11 -11
- package/src/components/Button/index.tsx +22 -2
- package/src/components/F0Button/F0Button.md +163 -0
- package/src/components/F0Button/F0Button.styles.ts +141 -0
- package/src/components/F0Button/F0Button.tsx +228 -0
- package/src/components/F0Button/F0Button.types.ts +82 -0
- package/src/components/F0Button/__tests__/F0Button.spec.tsx +285 -0
- package/src/components/F0Button/__tests__/__snapshots__/F0Button.spec.tsx.snap +966 -0
- package/src/components/F0Button/index.ts +7 -0
- package/src/components/Icon/index.tsx +5 -0
- package/src/components/exports.ts +1 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
} from "react"
|
|
8
|
+
import { View } from "react-native"
|
|
9
|
+
import Animated, {
|
|
10
|
+
Easing,
|
|
11
|
+
cancelAnimation,
|
|
12
|
+
useAnimatedStyle,
|
|
13
|
+
useSharedValue,
|
|
14
|
+
withRepeat,
|
|
15
|
+
withTiming,
|
|
16
|
+
} from "react-native-reanimated"
|
|
17
|
+
|
|
18
|
+
import { cn, omitProps } from "../../lib/utils"
|
|
19
|
+
import { F0Icon } from "../primitives/F0Icon"
|
|
20
|
+
import { F0Text } from "../primitives/F0Text"
|
|
21
|
+
import { PressableFeedback } from "../primitives/PressableFeedback"
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
buttonVariants,
|
|
25
|
+
loadingContentVariants,
|
|
26
|
+
loadingIndicatorVariants,
|
|
27
|
+
pressedVariants,
|
|
28
|
+
getIconColor,
|
|
29
|
+
getIconOnlyColor,
|
|
30
|
+
getTextColor,
|
|
31
|
+
} from "./F0Button.styles"
|
|
32
|
+
import {
|
|
33
|
+
F0_BUTTON_BLOCKED_FORWARD_PROPS,
|
|
34
|
+
type F0ButtonProps,
|
|
35
|
+
} from "./F0Button.types"
|
|
36
|
+
|
|
37
|
+
const F0Button = React.memo(
|
|
38
|
+
forwardRef<View, F0ButtonProps>(function F0Button(
|
|
39
|
+
{
|
|
40
|
+
label,
|
|
41
|
+
onPress,
|
|
42
|
+
disabled = false,
|
|
43
|
+
loading = false,
|
|
44
|
+
icon,
|
|
45
|
+
emoji,
|
|
46
|
+
hideLabel = false,
|
|
47
|
+
variant = "default",
|
|
48
|
+
size = "md",
|
|
49
|
+
round = false,
|
|
50
|
+
accessibilityHint,
|
|
51
|
+
showBadge = false,
|
|
52
|
+
fullWidth = false,
|
|
53
|
+
testID,
|
|
54
|
+
feedback = "both",
|
|
55
|
+
...rest
|
|
56
|
+
},
|
|
57
|
+
ref
|
|
58
|
+
) {
|
|
59
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
60
|
+
const [isPressed, setIsPressed] = useState(false)
|
|
61
|
+
const spinnerRotation = useSharedValue(0)
|
|
62
|
+
|
|
63
|
+
const isBusy = loading || isLoading
|
|
64
|
+
const isDisabled = disabled || isBusy
|
|
65
|
+
const isRound = hideLabel && round
|
|
66
|
+
|
|
67
|
+
const handlePress = useCallback(async () => {
|
|
68
|
+
if (!onPress || isDisabled) return
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const result = onPress()
|
|
72
|
+
|
|
73
|
+
if (result && typeof result.then === "function") {
|
|
74
|
+
setIsLoading(true)
|
|
75
|
+
try {
|
|
76
|
+
await result
|
|
77
|
+
} catch {
|
|
78
|
+
// Avoid bubbling async handler rejections from a design-system component.
|
|
79
|
+
} finally {
|
|
80
|
+
setIsLoading(false)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// Avoid bubbling sync exceptions from a design-system component.
|
|
85
|
+
}
|
|
86
|
+
}, [onPress, isDisabled])
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!isBusy) {
|
|
90
|
+
cancelAnimation(spinnerRotation)
|
|
91
|
+
spinnerRotation.value = 0
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
spinnerRotation.value = 0
|
|
96
|
+
spinnerRotation.value = withRepeat(
|
|
97
|
+
withTiming(1, {
|
|
98
|
+
duration: 1000,
|
|
99
|
+
easing: Easing.linear,
|
|
100
|
+
}),
|
|
101
|
+
-1,
|
|
102
|
+
false
|
|
103
|
+
)
|
|
104
|
+
}, [isBusy, spinnerRotation])
|
|
105
|
+
|
|
106
|
+
const handlePressIn = useCallback(() => {
|
|
107
|
+
if (!isDisabled) {
|
|
108
|
+
setIsPressed(true)
|
|
109
|
+
}
|
|
110
|
+
}, [isDisabled])
|
|
111
|
+
|
|
112
|
+
const handlePressOut = useCallback(() => {
|
|
113
|
+
setIsPressed(false)
|
|
114
|
+
}, [])
|
|
115
|
+
|
|
116
|
+
const baseClassName = useMemo(
|
|
117
|
+
() =>
|
|
118
|
+
buttonVariants({
|
|
119
|
+
variant,
|
|
120
|
+
size,
|
|
121
|
+
disabled: isDisabled,
|
|
122
|
+
round: isRound,
|
|
123
|
+
}),
|
|
124
|
+
[variant, size, isDisabled, isRound]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const accessibilityLabel = useMemo(() => {
|
|
128
|
+
const parts = [label]
|
|
129
|
+
if (isDisabled) parts.push("disabled")
|
|
130
|
+
if (isBusy) parts.push("loading")
|
|
131
|
+
return parts.join(", ")
|
|
132
|
+
}, [label, isBusy, isDisabled])
|
|
133
|
+
|
|
134
|
+
const shouldShowPressed = isPressed && !isDisabled
|
|
135
|
+
|
|
136
|
+
const className = shouldShowPressed
|
|
137
|
+
? cn(baseClassName, pressedVariants({ variant }))
|
|
138
|
+
: baseClassName
|
|
139
|
+
|
|
140
|
+
const iconIsOnly = isRound || (hideLabel && !emoji)
|
|
141
|
+
const iconColor = icon
|
|
142
|
+
? iconIsOnly
|
|
143
|
+
? getIconOnlyColor(variant, shouldShowPressed)
|
|
144
|
+
: getIconColor(variant, shouldShowPressed)
|
|
145
|
+
: undefined
|
|
146
|
+
const textColor = getTextColor(variant, shouldShowPressed)
|
|
147
|
+
const forwardedProps = omitProps(rest, F0_BUTTON_BLOCKED_FORWARD_PROPS)
|
|
148
|
+
const loadingIndicatorStyle = useAnimatedStyle(() => {
|
|
149
|
+
return {
|
|
150
|
+
borderTopColor: "transparent",
|
|
151
|
+
transform: [{ rotateZ: `${spinnerRotation.value * 360}deg` }],
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<View className={`flex ${fullWidth ? "flex-1" : "items-start"}`}>
|
|
157
|
+
<PressableFeedback
|
|
158
|
+
ref={ref}
|
|
159
|
+
{...forwardedProps}
|
|
160
|
+
disabled={isDisabled}
|
|
161
|
+
variant={feedback}
|
|
162
|
+
onPress={handlePress}
|
|
163
|
+
onPressIn={handlePressIn}
|
|
164
|
+
onPressOut={handlePressOut}
|
|
165
|
+
accessibilityLabel={accessibilityLabel}
|
|
166
|
+
accessibilityRole="button"
|
|
167
|
+
accessibilityState={{
|
|
168
|
+
disabled: isDisabled,
|
|
169
|
+
busy: isBusy,
|
|
170
|
+
}}
|
|
171
|
+
accessibilityHint={accessibilityHint}
|
|
172
|
+
testID={testID}
|
|
173
|
+
>
|
|
174
|
+
<View className={cn("relative", className)}>
|
|
175
|
+
<View
|
|
176
|
+
testID="f0-button-content"
|
|
177
|
+
className={loadingContentVariants({ loading: isBusy })}
|
|
178
|
+
>
|
|
179
|
+
<View className="flex-row items-center justify-center gap-1">
|
|
180
|
+
{icon && (
|
|
181
|
+
<F0Icon
|
|
182
|
+
icon={icon}
|
|
183
|
+
size="lg"
|
|
184
|
+
className={isRound ? undefined : "-ml-0.5"}
|
|
185
|
+
color={iconColor}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
{emoji && (
|
|
189
|
+
<F0Text variant="body-md-medium" color={textColor}>
|
|
190
|
+
{emoji}
|
|
191
|
+
</F0Text>
|
|
192
|
+
)}
|
|
193
|
+
{!hideLabel && (
|
|
194
|
+
<F0Text variant="body-md-medium" color={textColor}>
|
|
195
|
+
{label}
|
|
196
|
+
</F0Text>
|
|
197
|
+
)}
|
|
198
|
+
</View>
|
|
199
|
+
</View>
|
|
200
|
+
{isBusy && (
|
|
201
|
+
<View
|
|
202
|
+
pointerEvents="none"
|
|
203
|
+
className="absolute inset-0 items-center justify-center"
|
|
204
|
+
>
|
|
205
|
+
<Animated.View
|
|
206
|
+
testID="f0-button-loading-indicator"
|
|
207
|
+
accessibilityLabel="Loading indicator"
|
|
208
|
+
className={loadingIndicatorVariants({ variant, size })}
|
|
209
|
+
style={loadingIndicatorStyle}
|
|
210
|
+
/>
|
|
211
|
+
</View>
|
|
212
|
+
)}
|
|
213
|
+
</View>
|
|
214
|
+
</PressableFeedback>
|
|
215
|
+
{showBadge && variant === "outline" && (
|
|
216
|
+
<View
|
|
217
|
+
accessibilityLabel="Notification Badge"
|
|
218
|
+
className="absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full bg-f0-icon-accent"
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
221
|
+
</View>
|
|
222
|
+
)
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
F0Button.displayName = "F0Button"
|
|
227
|
+
|
|
228
|
+
export { F0Button }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { IconType } from "../primitives/F0Icon"
|
|
2
|
+
import type {
|
|
3
|
+
PressableFeedbackProps,
|
|
4
|
+
PressableFeedbackVariant,
|
|
5
|
+
} from "../primitives/PressableFeedback"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Button variant types
|
|
9
|
+
*/
|
|
10
|
+
export const BUTTON_VARIANTS = [
|
|
11
|
+
"default",
|
|
12
|
+
"outline",
|
|
13
|
+
"critical",
|
|
14
|
+
"neutral",
|
|
15
|
+
"ghost",
|
|
16
|
+
"promote",
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
export type ButtonVariant = (typeof BUTTON_VARIANTS)[number]
|
|
20
|
+
export type F0ButtonVariant = ButtonVariant
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Button size types
|
|
24
|
+
*/
|
|
25
|
+
export const BUTTON_SIZES = ["sm", "md", "lg"] as const
|
|
26
|
+
|
|
27
|
+
export type ButtonSize = (typeof BUTTON_SIZES)[number]
|
|
28
|
+
export type F0ButtonSize = ButtonSize
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Props that are controlled by F0Button and must not be passed through.
|
|
32
|
+
* This preserves F0Button press-state behavior and accessibility contract.
|
|
33
|
+
*/
|
|
34
|
+
const F0_BUTTON_CONTROLLED_PASSTHROUGH_PROPS = [
|
|
35
|
+
"onPressIn",
|
|
36
|
+
"onPressOut",
|
|
37
|
+
"accessibilityLabel",
|
|
38
|
+
"accessibilityRole",
|
|
39
|
+
"accessibilityState",
|
|
40
|
+
] as const
|
|
41
|
+
|
|
42
|
+
export const F0_BUTTON_BANNED_PROPS = ["style", "className"] as const
|
|
43
|
+
|
|
44
|
+
export const F0_BUTTON_BLOCKED_FORWARD_PROPS = [
|
|
45
|
+
...F0_BUTTON_CONTROLLED_PASSTHROUGH_PROPS,
|
|
46
|
+
...F0_BUTTON_BANNED_PROPS,
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
interface F0ButtonPropsInternal extends Omit<
|
|
50
|
+
PressableFeedbackProps,
|
|
51
|
+
| "children"
|
|
52
|
+
| "variant"
|
|
53
|
+
| "disabled"
|
|
54
|
+
| (typeof F0_BUTTON_CONTROLLED_PASSTHROUGH_PROPS)[number]
|
|
55
|
+
> {
|
|
56
|
+
label: string
|
|
57
|
+
onPress?: () => void | Promise<unknown>
|
|
58
|
+
variant?: ButtonVariant
|
|
59
|
+
size?: ButtonSize
|
|
60
|
+
disabled?: boolean
|
|
61
|
+
loading?: boolean
|
|
62
|
+
icon?: IconType
|
|
63
|
+
emoji?: string
|
|
64
|
+
hideLabel?: boolean
|
|
65
|
+
round?: boolean
|
|
66
|
+
showBadge?: boolean
|
|
67
|
+
fullWidth?: boolean
|
|
68
|
+
accessibilityHint?: string
|
|
69
|
+
testID?: string
|
|
70
|
+
feedback?: PressableFeedbackVariant
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Public props for the F0Button component.
|
|
75
|
+
*
|
|
76
|
+
* Note: `className` and `style` props are NOT available.
|
|
77
|
+
* Use semantic props (variant, size, etc.) for styling.
|
|
78
|
+
*/
|
|
79
|
+
export type F0ButtonProps = Omit<
|
|
80
|
+
F0ButtonPropsInternal,
|
|
81
|
+
(typeof F0_BUTTON_BANNED_PROPS)[number]
|
|
82
|
+
>
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { render, fireEvent, screen, act } from "@testing-library/react-native"
|
|
2
|
+
import React from "react"
|
|
3
|
+
|
|
4
|
+
import { F0Button } from "../"
|
|
5
|
+
import type { IconType } from "../../primitives/F0Icon"
|
|
6
|
+
|
|
7
|
+
jest.mock("../../primitives/F0Icon", () => ({
|
|
8
|
+
F0Icon: () => null,
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const mockIcon: IconType = "check" as unknown as IconType
|
|
12
|
+
const mockOnPress = jest.fn()
|
|
13
|
+
|
|
14
|
+
describe("F0Button", () => {
|
|
15
|
+
const defaultProps = {
|
|
16
|
+
label: "Test Button",
|
|
17
|
+
onPress: mockOnPress,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("Snapshot - default button", () => {
|
|
25
|
+
const { toJSON } = render(<F0Button {...defaultProps} />)
|
|
26
|
+
expect(toJSON()).toMatchSnapshot()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("Snapshot - outline variant", () => {
|
|
30
|
+
const { toJSON } = render(<F0Button {...defaultProps} variant="outline" />)
|
|
31
|
+
expect(toJSON()).toMatchSnapshot()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("Snapshot - critical variant", () => {
|
|
35
|
+
const { toJSON } = render(<F0Button {...defaultProps} variant="critical" />)
|
|
36
|
+
expect(toJSON()).toMatchSnapshot()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("Snapshot - with icon", () => {
|
|
40
|
+
const { toJSON } = render(<F0Button {...defaultProps} icon={mockIcon} />)
|
|
41
|
+
expect(toJSON()).toMatchSnapshot()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("Snapshot - with emoji", () => {
|
|
45
|
+
const { toJSON } = render(<F0Button {...defaultProps} emoji="👋" />)
|
|
46
|
+
expect(toJSON()).toMatchSnapshot()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("Snapshot - disabled state", () => {
|
|
50
|
+
const { toJSON } = render(<F0Button {...defaultProps} disabled />)
|
|
51
|
+
expect(toJSON()).toMatchSnapshot()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("Snapshot - loading state", () => {
|
|
55
|
+
const { toJSON } = render(<F0Button {...defaultProps} loading />)
|
|
56
|
+
expect(toJSON()).toMatchSnapshot()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("renders loading indicator and hides content when loading", () => {
|
|
60
|
+
render(<F0Button {...defaultProps} loading />)
|
|
61
|
+
|
|
62
|
+
expect(screen.getByTestId("f0-button-loading-indicator")).toBeDefined()
|
|
63
|
+
expect(screen.getByTestId("f0-button-content").props.className).toContain(
|
|
64
|
+
"opacity-0"
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("Snapshot - different sizes", () => {
|
|
69
|
+
const sizes = ["sm", "md", "lg"] as const
|
|
70
|
+
sizes.forEach((size) => {
|
|
71
|
+
const { toJSON } = render(<F0Button {...defaultProps} size={size} />)
|
|
72
|
+
expect(toJSON()).toMatchSnapshot()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("Snapshot - round button with hidden label", () => {
|
|
77
|
+
const { toJSON } = render(<F0Button {...defaultProps} round hideLabel />)
|
|
78
|
+
expect(toJSON()).toMatchSnapshot()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("renders correctly with required props", () => {
|
|
82
|
+
render(<F0Button {...defaultProps} />)
|
|
83
|
+
const button = screen.getByText("Test Button")
|
|
84
|
+
expect(button).toBeDefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it("handles press events", () => {
|
|
88
|
+
render(<F0Button {...defaultProps} />)
|
|
89
|
+
fireEvent.press(screen.getByRole("button"))
|
|
90
|
+
expect(mockOnPress).toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("does not call onPress when disabled", () => {
|
|
94
|
+
render(<F0Button {...defaultProps} disabled />)
|
|
95
|
+
fireEvent.press(screen.getByRole("button"))
|
|
96
|
+
expect(mockOnPress).not.toHaveBeenCalled()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("shows correct accessibility label when disabled", () => {
|
|
100
|
+
render(<F0Button {...defaultProps} disabled />)
|
|
101
|
+
const button = screen.getByRole("button")
|
|
102
|
+
expect(button.props.accessibilityLabel).toBe("Test Button, disabled")
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it("shows correct accessibility label when loading", () => {
|
|
106
|
+
render(<F0Button {...defaultProps} loading />)
|
|
107
|
+
const button = screen.getByRole("button")
|
|
108
|
+
expect(button.props.accessibilityLabel).toBe(
|
|
109
|
+
"Test Button, disabled, loading"
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("sets correct accessibilityState when disabled", () => {
|
|
114
|
+
render(<F0Button {...defaultProps} disabled />)
|
|
115
|
+
const button = screen.getByRole("button")
|
|
116
|
+
expect(button.props.accessibilityState).toMatchObject({
|
|
117
|
+
disabled: true,
|
|
118
|
+
busy: false,
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("sets correct accessibilityState when loading", () => {
|
|
123
|
+
render(<F0Button {...defaultProps} loading />)
|
|
124
|
+
const button = screen.getByRole("button")
|
|
125
|
+
expect(button.props.accessibilityState).toMatchObject({
|
|
126
|
+
disabled: true,
|
|
127
|
+
busy: true,
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("hides label text when hideLabel is true", () => {
|
|
132
|
+
render(<F0Button {...defaultProps} hideLabel />)
|
|
133
|
+
expect(screen.queryByText("Test Button")).toBeNull()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("uses label as accessibilityLabel even when hideLabel is true", () => {
|
|
137
|
+
render(<F0Button {...defaultProps} hideLabel />)
|
|
138
|
+
const button = screen.getByRole("button")
|
|
139
|
+
expect(button.props.accessibilityLabel).toBe("Test Button")
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it("renders with testID", () => {
|
|
143
|
+
render(<F0Button {...defaultProps} testID="my-button" />)
|
|
144
|
+
expect(screen.getByTestId("my-button")).toBeDefined()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("renders badge for outline variant with showBadge", () => {
|
|
148
|
+
render(<F0Button {...defaultProps} variant="outline" showBadge />)
|
|
149
|
+
expect(screen.getByLabelText("Notification Badge")).toBeDefined()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("does not render badge for non-outline variant even with showBadge", () => {
|
|
153
|
+
render(<F0Button {...defaultProps} variant="default" showBadge />)
|
|
154
|
+
expect(screen.queryByLabelText("Notification Badge")).toBeNull()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("does not render badge when showBadge is false", () => {
|
|
158
|
+
render(<F0Button {...defaultProps} variant="outline" />)
|
|
159
|
+
expect(screen.queryByLabelText("Notification Badge")).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("auto-enters loading state for async onPress", async () => {
|
|
163
|
+
let resolvePromise: () => void
|
|
164
|
+
const asyncOnPress = jest.fn(
|
|
165
|
+
() =>
|
|
166
|
+
new Promise<void>((resolve) => {
|
|
167
|
+
resolvePromise = resolve
|
|
168
|
+
})
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
render(<F0Button label="Async" onPress={asyncOnPress} />)
|
|
172
|
+
|
|
173
|
+
await act(async () => {
|
|
174
|
+
fireEvent.press(screen.getByRole("button"))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(asyncOnPress).toHaveBeenCalled()
|
|
178
|
+
expect(screen.getByTestId("f0-button-loading-indicator")).toBeDefined()
|
|
179
|
+
expect(screen.getByTestId("f0-button-content").props.className).toContain(
|
|
180
|
+
"opacity-0"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
await act(async () => {
|
|
184
|
+
resolvePromise!()
|
|
185
|
+
await asyncOnPress.mock.results[0].value
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(screen.queryByTestId("f0-button-loading-indicator")).toBeNull()
|
|
189
|
+
expect(screen.getByTestId("f0-button-content").props.className).toContain(
|
|
190
|
+
"opacity-100"
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("handles rejected async onPress without leaking rejections", async () => {
|
|
195
|
+
const onPressError = new Error("Async failure")
|
|
196
|
+
const asyncOnPress = jest.fn().mockRejectedValue(onPressError)
|
|
197
|
+
render(<F0Button label="Async reject" onPress={asyncOnPress} />)
|
|
198
|
+
|
|
199
|
+
await act(async () => {
|
|
200
|
+
fireEvent.press(screen.getByRole("button"))
|
|
201
|
+
await Promise.resolve()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(asyncOnPress).toHaveBeenCalled()
|
|
205
|
+
expect(screen.queryByTestId("f0-button-loading-indicator")).toBeNull()
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("handles sync throw in onPress without throwing from press handler", () => {
|
|
209
|
+
const onPressError = new Error("Sync failure")
|
|
210
|
+
const throwingOnPress = jest.fn(() => {
|
|
211
|
+
throw onPressError
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
render(<F0Button label="Sync throw" onPress={throwingOnPress} />)
|
|
215
|
+
expect(() => {
|
|
216
|
+
fireEvent.press(screen.getByRole("button"))
|
|
217
|
+
}).not.toThrow()
|
|
218
|
+
|
|
219
|
+
expect(throwingOnPress).toHaveBeenCalled()
|
|
220
|
+
expect(screen.queryByTestId("f0-button-loading-indicator")).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it("renders all six variants without error", () => {
|
|
224
|
+
const allVariants = [
|
|
225
|
+
"default",
|
|
226
|
+
"outline",
|
|
227
|
+
"critical",
|
|
228
|
+
"neutral",
|
|
229
|
+
"ghost",
|
|
230
|
+
"promote",
|
|
231
|
+
] as const
|
|
232
|
+
|
|
233
|
+
allVariants.forEach((variant) => {
|
|
234
|
+
const { unmount } = render(<F0Button label={variant} variant={variant} />)
|
|
235
|
+
expect(screen.getByText(variant)).toBeDefined()
|
|
236
|
+
unmount()
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it("ignores className passed via unsafe cast", () => {
|
|
241
|
+
const unsafeProps = {
|
|
242
|
+
...defaultProps,
|
|
243
|
+
className: "bg-red-500 p-9",
|
|
244
|
+
} as unknown as React.ComponentProps<typeof F0Button>
|
|
245
|
+
render(<F0Button {...unsafeProps} />)
|
|
246
|
+
|
|
247
|
+
const button = screen.getByRole("button")
|
|
248
|
+
expect(button.props.className).toBe("overflow-hidden")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("ignores style passed via unsafe cast", () => {
|
|
252
|
+
const unsafeProps = {
|
|
253
|
+
...defaultProps,
|
|
254
|
+
style: { padding: 999 },
|
|
255
|
+
} as unknown as React.ComponentProps<typeof F0Button>
|
|
256
|
+
render(<F0Button {...unsafeProps} />)
|
|
257
|
+
|
|
258
|
+
const button = screen.getByRole("button")
|
|
259
|
+
expect(button.props.style?.[1]).toBeUndefined()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it("ignores onPressIn passed via unsafe cast", () => {
|
|
263
|
+
const unsafeOnPressIn = jest.fn()
|
|
264
|
+
const unsafeProps = {
|
|
265
|
+
...defaultProps,
|
|
266
|
+
onPressIn: unsafeOnPressIn,
|
|
267
|
+
} as unknown as React.ComponentProps<typeof F0Button>
|
|
268
|
+
render(<F0Button {...unsafeProps} />)
|
|
269
|
+
|
|
270
|
+
const button = screen.getByRole("button")
|
|
271
|
+
fireEvent(button, "pressIn")
|
|
272
|
+
expect(unsafeOnPressIn).not.toHaveBeenCalled()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it("ignores accessibilityLabel passed via unsafe cast", () => {
|
|
276
|
+
const unsafeProps = {
|
|
277
|
+
...defaultProps,
|
|
278
|
+
accessibilityLabel: "Unsafe label override",
|
|
279
|
+
} as unknown as React.ComponentProps<typeof F0Button>
|
|
280
|
+
render(<F0Button {...unsafeProps} />)
|
|
281
|
+
|
|
282
|
+
const button = screen.getByRole("button")
|
|
283
|
+
expect(button.props.accessibilityLabel).toBe("Test Button")
|
|
284
|
+
})
|
|
285
|
+
})
|