@factorialco/f0-react-native 0.33.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/Badge/index.js +1 -1
- package/lib/module/components/Badge/index.js.map +1 -1
- package/lib/module/components/Button/index.js +1 -1
- package/lib/module/components/Button/index.js.map +1 -1
- package/lib/module/components/F0Badge/F0Badge.js +2 -0
- package/lib/module/components/F0Badge/F0Badge.js.map +1 -0
- package/lib/module/components/F0Badge/F0Badge.md +67 -0
- package/lib/module/components/F0Badge/F0Badge.styles.js +2 -0
- package/lib/module/components/F0Badge/F0Badge.styles.js.map +1 -0
- package/lib/module/components/F0Badge/F0Badge.types.js +2 -0
- package/lib/module/components/F0Badge/F0Badge.types.js.map +1 -0
- package/lib/module/components/F0Badge/index.js +2 -0
- package/lib/module/components/F0Badge/index.js.map +1 -0
- 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/Badge/index.d.ts +28 -54
- package/lib/typescript/components/Badge/index.d.ts.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/F0Badge/F0Badge.d.ts +5 -0
- package/lib/typescript/components/F0Badge/F0Badge.d.ts.map +1 -0
- package/lib/typescript/components/F0Badge/F0Badge.styles.d.ts +57 -0
- package/lib/typescript/components/F0Badge/F0Badge.styles.d.ts.map +1 -0
- package/lib/typescript/components/F0Badge/F0Badge.types.d.ts +19 -0
- package/lib/typescript/components/F0Badge/F0Badge.types.d.ts.map +1 -0
- package/lib/typescript/components/F0Badge/index.d.ts +4 -0
- package/lib/typescript/components/F0Badge/index.d.ts.map +1 -0
- 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 +2 -0
- package/lib/typescript/components/exports.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/Badge/__tests__/index.spec.tsx +29 -0
- package/src/components/Badge/index.tsx +38 -40
- package/src/components/Button/__snapshots__/index.spec.tsx.snap +11 -11
- package/src/components/Button/index.tsx +22 -2
- package/src/components/F0Badge/F0Badge.md +67 -0
- package/src/components/F0Badge/F0Badge.styles.ts +28 -0
- package/src/components/F0Badge/F0Badge.tsx +24 -0
- package/src/components/F0Badge/F0Badge.types.ts +34 -0
- package/src/components/F0Badge/__tests__/F0Badge.spec.tsx +63 -0
- package/src/components/F0Badge/__tests__/__snapshots__/F0Badge.spec.tsx.snap +651 -0
- package/src/components/F0Badge/index.ts +7 -0
- 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 +2 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# F0Button
|
|
2
|
+
|
|
3
|
+
Primary interactive button component for the F0 Design System.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
F0Button is the semantic button API for React Native in F0. It supports:
|
|
8
|
+
|
|
9
|
+
- Visual variants (`default`, `outline`, `critical`, `neutral`, `ghost`, `promote`)
|
|
10
|
+
- Size variants (`sm`, `md`, `lg`)
|
|
11
|
+
- Optional icon / emoji content
|
|
12
|
+
- Icon-only round mode
|
|
13
|
+
- Async press handlers with automatic loading-state handling
|
|
14
|
+
- Visible loading indicator when busy
|
|
15
|
+
- Press feedback control through `PressableFeedback`
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
- **Pattern:** Props API (Atomic) - element order is fixed: icon -> emoji -> label
|
|
20
|
+
- **Press feedback:** Uses `PressableFeedback` component for smooth animations
|
|
21
|
+
- **Press state:** Tracks pressed state with `useState` for color changes by variant
|
|
22
|
+
- **Loading state:** Supports auto-loading when `onPress` returns a Promise
|
|
23
|
+
- **Styling:** `className` and `style` are blocked in the public API and filtered at runtime
|
|
24
|
+
- **Location:** `src/components/F0Button/`
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
<!-- prettier-ignore -->
|
|
29
|
+
```tsx
|
|
30
|
+
import { F0Button } from "@factorialco/f0-react-native"
|
|
31
|
+
import { Archive } from "@factorialco/f0-react-native/icons/app"
|
|
32
|
+
|
|
33
|
+
<F0Button label="Submit" onPress={handleSubmit} />
|
|
34
|
+
|
|
35
|
+
<F0Button label="Delete" variant="critical" icon={Archive} />
|
|
36
|
+
|
|
37
|
+
<F0Button label="Add" icon={Archive} hideLabel round />
|
|
38
|
+
|
|
39
|
+
<F0Button label="Save" onPress={async () => await saveData()} />
|
|
40
|
+
|
|
41
|
+
<F0Button label="Love" emoji="🥰" variant="neutral" />
|
|
42
|
+
|
|
43
|
+
<F0Button label="Custom" feedback="scale" />
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Props
|
|
47
|
+
|
|
48
|
+
| Prop | Type | Default | Description |
|
|
49
|
+
| ------------------- | -------------------------------- | ----------- | ---------------------------------------------------------- |
|
|
50
|
+
| `label` | `string` | required | Visible label and accessibility base label |
|
|
51
|
+
| `onPress` | `() => void \| Promise<unknown>` | — | Press handler; async return enables auto-loading |
|
|
52
|
+
| `variant` | `F0ButtonVariant` | `"default"` | Visual style variant |
|
|
53
|
+
| `size` | `F0ButtonSize` | `"md"` | Control height and radius |
|
|
54
|
+
| `disabled` | `boolean` | `false` | Disabled state |
|
|
55
|
+
| `loading` | `boolean` | `false` | External loading control |
|
|
56
|
+
| `icon` | `IconType` | — | Optional icon |
|
|
57
|
+
| `emoji` | `string` | — | Optional emoji |
|
|
58
|
+
| `hideLabel` | `boolean` | `false` | Hide visible label (keeps accessibility label) |
|
|
59
|
+
| `round` | `boolean` | `false` | Makes icon-only mode circular |
|
|
60
|
+
| `showBadge` | `boolean` | `false` | Notification badge (outline variant only) |
|
|
61
|
+
| `fullWidth` | `boolean` | `false` | Makes button fill available horizontal space |
|
|
62
|
+
| `feedback` | `PressableFeedbackVariant` | `"both"` | Press feedback mode (`both`, `scale`, `highlight`, `none`) |
|
|
63
|
+
| `accessibilityHint` | `string` | — | Optional screen-reader hint |
|
|
64
|
+
| `testID` | `string` | — | Test identifier |
|
|
65
|
+
|
|
66
|
+
### Variants
|
|
67
|
+
|
|
68
|
+
- `default` - primary/high-emphasis action
|
|
69
|
+
- `outline` - secondary action with border
|
|
70
|
+
- `critical` - destructive action
|
|
71
|
+
- `neutral` - neutral secondary action
|
|
72
|
+
- `ghost` - subtle transparent action
|
|
73
|
+
- `promote` - promotional/highlighted action
|
|
74
|
+
|
|
75
|
+
### Sizes
|
|
76
|
+
|
|
77
|
+
- `sm` - compact
|
|
78
|
+
- `md` - default
|
|
79
|
+
- `lg` - large
|
|
80
|
+
|
|
81
|
+
## Runtime Behavior
|
|
82
|
+
|
|
83
|
+
### Async onPress auto-loading
|
|
84
|
+
|
|
85
|
+
If `onPress` returns a Promise, F0Button enters internal loading state until it resolves.
|
|
86
|
+
|
|
87
|
+
<!-- prettier-ignore -->
|
|
88
|
+
```tsx
|
|
89
|
+
<F0Button
|
|
90
|
+
label="Save"
|
|
91
|
+
onPress={async () => {
|
|
92
|
+
await saveData()
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Loading visuals
|
|
98
|
+
|
|
99
|
+
When `loading` is `true` (external) or an async `onPress` is pending (internal):
|
|
100
|
+
|
|
101
|
+
- the button becomes non-interactive (`disabled`)
|
|
102
|
+
- a centered spinner indicator is rendered
|
|
103
|
+
- button content is visually hidden (`opacity`) to keep layout stable
|
|
104
|
+
|
|
105
|
+
### Press feedback
|
|
106
|
+
|
|
107
|
+
F0Button delegates touch feedback to `PressableFeedback` and supports semantic control via `feedback`.
|
|
108
|
+
|
|
109
|
+
<!-- prettier-ignore -->
|
|
110
|
+
```tsx
|
|
111
|
+
<F0Button label="Both" feedback="both" />
|
|
112
|
+
<F0Button label="Scale only" feedback="scale" />
|
|
113
|
+
<F0Button label="Highlight only" feedback="highlight" />
|
|
114
|
+
<F0Button label="No visual feedback" feedback="none" />
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Styling Contract
|
|
118
|
+
|
|
119
|
+
F0Button is semantic-first:
|
|
120
|
+
|
|
121
|
+
- `className` is **not** part of `F0ButtonProps`
|
|
122
|
+
- `style` is **not** part of `F0ButtonProps`
|
|
123
|
+
- Both are filtered at runtime before forwarding props to internal pressable primitives
|
|
124
|
+
|
|
125
|
+
This guarantees semantic variants remain source-of-truth and prevents style overrides from bypassing the API contract.
|
|
126
|
+
|
|
127
|
+
## Accessibility
|
|
128
|
+
|
|
129
|
+
- Always sets `accessibilityRole="button"`
|
|
130
|
+
- Auto-builds `accessibilityLabel` from `label` plus state:
|
|
131
|
+
- `"label, disabled"`
|
|
132
|
+
- `"label, disabled, loading"`
|
|
133
|
+
- Exposes `accessibilityState` with:
|
|
134
|
+
- `disabled`
|
|
135
|
+
- `busy`
|
|
136
|
+
- `hideLabel` does not remove accessibility label
|
|
137
|
+
|
|
138
|
+
## Testing
|
|
139
|
+
|
|
140
|
+
Main test suite:
|
|
141
|
+
|
|
142
|
+
- `src/components/F0Button/__tests__/F0Button.spec.tsx`
|
|
143
|
+
|
|
144
|
+
Coverage includes:
|
|
145
|
+
|
|
146
|
+
- snapshots across variants/sizes/states
|
|
147
|
+
- async loading behavior
|
|
148
|
+
- accessibility label/state
|
|
149
|
+
- badge rendering rules
|
|
150
|
+
- runtime blocking of `className`/`style`
|
|
151
|
+
|
|
152
|
+
## File Structure
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
src/components/F0Button/
|
|
156
|
+
├── F0Button.tsx
|
|
157
|
+
├── F0Button.types.ts
|
|
158
|
+
├── F0Button.styles.ts
|
|
159
|
+
├── F0Button.md
|
|
160
|
+
├── __tests__/
|
|
161
|
+
│ └── F0Button.spec.tsx
|
|
162
|
+
└── index.ts
|
|
163
|
+
```
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { tv } from "tailwind-variants"
|
|
2
|
+
|
|
3
|
+
import type { IconColor } from "../primitives/F0Icon"
|
|
4
|
+
import type { TextColor } from "../primitives/F0Text"
|
|
5
|
+
|
|
6
|
+
import type { ButtonVariant } from "./F0Button.types"
|
|
7
|
+
|
|
8
|
+
export const buttonVariants = tv({
|
|
9
|
+
base: "flex-row items-center justify-center rounded border-none grow-0",
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-f0-background-accent-bold",
|
|
13
|
+
outline: "bg-f0-background-inverse-secondary border border-f0-border",
|
|
14
|
+
neutral: "bg-f0-background-secondary",
|
|
15
|
+
critical: "bg-f0-background-secondary border border-f0-border",
|
|
16
|
+
ghost: "bg-transparent",
|
|
17
|
+
promote: "bg-f0-background-promote border border-f0-border-promote",
|
|
18
|
+
},
|
|
19
|
+
size: {
|
|
20
|
+
sm: "h-6 rounded-sm",
|
|
21
|
+
md: "h-8 rounded",
|
|
22
|
+
lg: "h-10 rounded-md",
|
|
23
|
+
},
|
|
24
|
+
disabled: {
|
|
25
|
+
true: "opacity-50",
|
|
26
|
+
false: "",
|
|
27
|
+
},
|
|
28
|
+
round: {
|
|
29
|
+
true: "aspect-square p-0",
|
|
30
|
+
false: "gap-1 px-2 sm:px-3 lg:px-4",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: {
|
|
34
|
+
variant: "default",
|
|
35
|
+
size: "md",
|
|
36
|
+
disabled: false,
|
|
37
|
+
round: false,
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
export const pressedVariants = tv({
|
|
42
|
+
base: "",
|
|
43
|
+
variants: {
|
|
44
|
+
variant: {
|
|
45
|
+
default: "bg-f0-background-accent-bold-hover",
|
|
46
|
+
outline: "bg-f0-background-tertiary border-opacity-70",
|
|
47
|
+
neutral: "bg-f0-background-secondary-hover",
|
|
48
|
+
critical: "bg-f0-background-critical-bold border-transparent",
|
|
49
|
+
ghost: "bg-f0-background-secondary-hover",
|
|
50
|
+
promote: "bg-f0-background-promote-hover",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
defaultVariants: {
|
|
54
|
+
variant: "default",
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export const loadingContentVariants = tv({
|
|
59
|
+
variants: {
|
|
60
|
+
loading: {
|
|
61
|
+
true: "opacity-0",
|
|
62
|
+
false: "opacity-100",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
defaultVariants: {
|
|
66
|
+
loading: false,
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export const loadingIndicatorVariants = tv({
|
|
71
|
+
base: "rounded-full border-solid border-t-transparent",
|
|
72
|
+
variants: {
|
|
73
|
+
variant: {
|
|
74
|
+
default: "border-f0-foreground-inverse",
|
|
75
|
+
outline: "border-f0-foreground",
|
|
76
|
+
neutral: "border-f0-foreground",
|
|
77
|
+
critical: "border-f0-icon-critical",
|
|
78
|
+
ghost: "border-f0-foreground",
|
|
79
|
+
promote: "border-f0-icon-promote",
|
|
80
|
+
},
|
|
81
|
+
size: {
|
|
82
|
+
sm: "h-3 w-3 border",
|
|
83
|
+
md: "h-4 w-4 border-2",
|
|
84
|
+
lg: "h-5 w-5 border-2",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
defaultVariants: {
|
|
88
|
+
variant: "default",
|
|
89
|
+
size: "md",
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
export const getIconColor = (
|
|
94
|
+
variant: ButtonVariant,
|
|
95
|
+
isPressed: boolean
|
|
96
|
+
): IconColor => {
|
|
97
|
+
switch (variant) {
|
|
98
|
+
case "default":
|
|
99
|
+
return "inverse"
|
|
100
|
+
case "critical":
|
|
101
|
+
return isPressed ? "inverse" : "critical-bold"
|
|
102
|
+
default:
|
|
103
|
+
return "default"
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const getIconOnlyColor = (
|
|
108
|
+
variant: ButtonVariant,
|
|
109
|
+
isPressed: boolean
|
|
110
|
+
): IconColor => {
|
|
111
|
+
switch (variant) {
|
|
112
|
+
case "critical":
|
|
113
|
+
return isPressed ? "inverse" : "critical-bold"
|
|
114
|
+
case "default":
|
|
115
|
+
return "inverse"
|
|
116
|
+
case "outline":
|
|
117
|
+
case "neutral":
|
|
118
|
+
case "ghost":
|
|
119
|
+
case "promote":
|
|
120
|
+
default:
|
|
121
|
+
return "bold"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const getTextColor = (
|
|
126
|
+
variant: ButtonVariant,
|
|
127
|
+
isPressed: boolean
|
|
128
|
+
): TextColor => {
|
|
129
|
+
if (isPressed && variant === "critical") {
|
|
130
|
+
return "inverse"
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
switch (variant) {
|
|
134
|
+
case "default":
|
|
135
|
+
return "inverse"
|
|
136
|
+
case "critical":
|
|
137
|
+
return "critical"
|
|
138
|
+
default:
|
|
139
|
+
return "default"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -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
|
+
>
|