@idealyst/components 1.0.41 → 1.0.44
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/CLAUDE.md +57 -3
- package/package.json +2 -2
- package/src/Dialog/Dialog.native.tsx +91 -0
- package/src/Dialog/Dialog.styles.tsx +148 -0
- package/src/Dialog/Dialog.web.tsx +170 -0
- package/src/Dialog/README.md +210 -0
- package/src/Dialog/index.native.ts +2 -0
- package/src/Dialog/index.ts +2 -0
- package/src/Dialog/index.web.ts +2 -0
- package/src/Dialog/types.ts +63 -0
- package/src/Input/Input.native.tsx +12 -3
- package/src/Input/Input.styles.tsx +23 -0
- package/src/Input/Input.web.tsx +34 -6
- package/src/Input/types.ts +11 -1
- package/src/Popover/Popover.native.tsx +87 -0
- package/src/Popover/Popover.styles.tsx +96 -0
- package/src/Popover/Popover.web.tsx +287 -0
- package/src/Popover/index.native.ts +2 -0
- package/src/Popover/index.ts +2 -0
- package/src/Popover/index.web.ts +2 -0
- package/src/Popover/types.ts +65 -0
- package/src/examples/AllExamples.tsx +8 -0
- package/src/examples/DialogExamples.tsx +157 -0
- package/src/examples/PopoverExamples.tsx +155 -0
- package/src/examples/index.ts +2 -0
- package/src/index.native.ts +9 -0
- package/src/index.ts +8 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Dialog Component
|
|
2
|
+
|
|
3
|
+
A modal dialog component that creates a global overlay across the entire application. The Dialog component works consistently across web and React Native platforms.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Global Overlay**: Appears above all other content including navigation bars and tab bars
|
|
8
|
+
- **Cross-Platform**: Uses React portals on web and Modal component on React Native
|
|
9
|
+
- **Accessibility**: Focus management, escape key support (web), hardware back button handling (native)
|
|
10
|
+
- **Theme Integration**: Supports intent-based colors and Unistyles variants
|
|
11
|
+
- **Flexible Sizing**: Multiple size options from small to fullscreen
|
|
12
|
+
- **Backdrop Interaction**: Configurable backdrop click behavior
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { Dialog, Button, Text } from '@idealyst/components';
|
|
18
|
+
import { useState } from 'react';
|
|
19
|
+
|
|
20
|
+
const MyComponent = () => {
|
|
21
|
+
const [open, setOpen] = useState(false);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<Button onPress={() => setOpen(true)}>
|
|
26
|
+
Open Dialog
|
|
27
|
+
</Button>
|
|
28
|
+
|
|
29
|
+
<Dialog
|
|
30
|
+
open={open}
|
|
31
|
+
onOpenChange={setOpen}
|
|
32
|
+
title="Dialog Title"
|
|
33
|
+
size="medium"
|
|
34
|
+
variant="default"
|
|
35
|
+
intent="primary"
|
|
36
|
+
>
|
|
37
|
+
<Text>Dialog content goes here</Text>
|
|
38
|
+
<Button onPress={() => setOpen(false)}>
|
|
39
|
+
Close
|
|
40
|
+
</Button>
|
|
41
|
+
</Dialog>
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Props
|
|
48
|
+
|
|
49
|
+
| Prop | Type | Default | Description |
|
|
50
|
+
|------|------|---------|-------------|
|
|
51
|
+
| `open` | `boolean` | - | Whether the dialog is visible |
|
|
52
|
+
| `onOpenChange` | `(open: boolean) => void` | - | Called when dialog should open/close |
|
|
53
|
+
| `title` | `string` | - | Optional title displayed in header |
|
|
54
|
+
| `children` | `ReactNode` | - | Content to display inside dialog |
|
|
55
|
+
| `size` | `'small' \| 'medium' \| 'large' \| 'fullscreen'` | `'medium'` | Size of the dialog |
|
|
56
|
+
| `variant` | `'default' \| 'alert' \| 'confirmation'` | `'default'` | Visual style variant |
|
|
57
|
+
| `intent` | `'primary' \| 'neutral' \| 'success' \| 'error' \| 'warning'` | `'primary'` | Color scheme/semantic meaning |
|
|
58
|
+
| `showCloseButton` | `boolean` | `true` | Whether to show close button in header |
|
|
59
|
+
| `closeOnBackdropClick` | `boolean` | `true` | Whether clicking backdrop closes dialog |
|
|
60
|
+
| `closeOnEscapeKey` | `boolean` | `true` | Whether escape key closes dialog (web only) |
|
|
61
|
+
| `animationType` | `'slide' \| 'fade' \| 'none'` | `'fade'` | Animation type (native only) |
|
|
62
|
+
| `style` | `any` | - | Additional platform-specific styles |
|
|
63
|
+
| `testID` | `string` | - | Test identifier |
|
|
64
|
+
|
|
65
|
+
## Variants
|
|
66
|
+
|
|
67
|
+
### Size Variants
|
|
68
|
+
- **small**: 400px max width, suitable for simple alerts
|
|
69
|
+
- **medium**: 600px max width, good for forms and content
|
|
70
|
+
- **large**: 800px max width, for complex layouts
|
|
71
|
+
- **fullscreen**: Full screen coverage, removes border radius
|
|
72
|
+
|
|
73
|
+
### Visual Variants
|
|
74
|
+
- **default**: Standard dialog appearance
|
|
75
|
+
- **alert**: Adds colored top border for alerts
|
|
76
|
+
- **confirmation**: Adds colored top border for confirmations
|
|
77
|
+
|
|
78
|
+
### Intent Colors
|
|
79
|
+
When used with `alert` or `confirmation` variants:
|
|
80
|
+
- **primary**: Blue top border
|
|
81
|
+
- **success**: Green top border
|
|
82
|
+
- **error**: Red top border
|
|
83
|
+
- **warning**: Orange top border
|
|
84
|
+
- **neutral**: Gray top border
|
|
85
|
+
|
|
86
|
+
## Platform Differences
|
|
87
|
+
|
|
88
|
+
### Web Implementation
|
|
89
|
+
- Uses React portals to render into `document.body`
|
|
90
|
+
- Supports escape key to close
|
|
91
|
+
- Automatic focus management and restoration
|
|
92
|
+
- Click outside to close (configurable)
|
|
93
|
+
- Body scroll prevention when open
|
|
94
|
+
|
|
95
|
+
### React Native Implementation
|
|
96
|
+
- Uses React Native's `Modal` component
|
|
97
|
+
- Hardware back button handling on Android
|
|
98
|
+
- Touch outside to close (configurable)
|
|
99
|
+
- Configurable animation types
|
|
100
|
+
|
|
101
|
+
## Examples
|
|
102
|
+
|
|
103
|
+
### Basic Dialog
|
|
104
|
+
```tsx
|
|
105
|
+
<Dialog
|
|
106
|
+
open={isOpen}
|
|
107
|
+
onOpenChange={setIsOpen}
|
|
108
|
+
title="Basic Dialog"
|
|
109
|
+
>
|
|
110
|
+
<Text>Simple dialog content</Text>
|
|
111
|
+
</Dialog>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Alert Dialog
|
|
115
|
+
```tsx
|
|
116
|
+
<Dialog
|
|
117
|
+
open={alertOpen}
|
|
118
|
+
onOpenChange={setAlertOpen}
|
|
119
|
+
title="Important Alert"
|
|
120
|
+
variant="alert"
|
|
121
|
+
intent="warning"
|
|
122
|
+
>
|
|
123
|
+
<Text>This is an important message!</Text>
|
|
124
|
+
</Dialog>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Confirmation Dialog
|
|
128
|
+
```tsx
|
|
129
|
+
<Dialog
|
|
130
|
+
open={confirmOpen}
|
|
131
|
+
onOpenChange={setConfirmOpen}
|
|
132
|
+
title="Confirm Action"
|
|
133
|
+
variant="confirmation"
|
|
134
|
+
intent="error"
|
|
135
|
+
closeOnBackdropClick={false}
|
|
136
|
+
>
|
|
137
|
+
<Text>Are you sure you want to delete this item?</Text>
|
|
138
|
+
<View style={{ flexDirection: 'row', gap: 12, marginTop: 16 }}>
|
|
139
|
+
<Button variant="outlined" onPress={() => setConfirmOpen(false)}>
|
|
140
|
+
Cancel
|
|
141
|
+
</Button>
|
|
142
|
+
<Button variant="contained" intent="error" onPress={handleDelete}>
|
|
143
|
+
Delete
|
|
144
|
+
</Button>
|
|
145
|
+
</View>
|
|
146
|
+
</Dialog>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Fullscreen Dialog
|
|
150
|
+
```tsx
|
|
151
|
+
<Dialog
|
|
152
|
+
open={fullscreenOpen}
|
|
153
|
+
onOpenChange={setFullscreenOpen}
|
|
154
|
+
title="Fullscreen Dialog"
|
|
155
|
+
size="fullscreen"
|
|
156
|
+
>
|
|
157
|
+
<Text>This dialog covers the entire screen</Text>
|
|
158
|
+
</Dialog>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Accessibility
|
|
162
|
+
|
|
163
|
+
- **Focus Management**: Automatically focuses dialog on open and restores focus on close (web)
|
|
164
|
+
- **Keyboard Navigation**: Escape key support on web
|
|
165
|
+
- **Screen Readers**: Proper ARIA roles and labels
|
|
166
|
+
- **Touch Targets**: Minimum 44px touch targets for interactive elements
|
|
167
|
+
- **Hardware Back Button**: Handled automatically on Android
|
|
168
|
+
|
|
169
|
+
## Best Practices
|
|
170
|
+
|
|
171
|
+
1. **Use appropriate sizes**: Choose size based on content complexity
|
|
172
|
+
2. **Provide clear actions**: Always include a way to close the dialog
|
|
173
|
+
3. **Use variants appropriately**: Choose alert or confirmation for important dialogs
|
|
174
|
+
4. **Handle confirmation dialogs carefully**: Disable backdrop close for destructive actions
|
|
175
|
+
5. **Keep content focused**: Dialogs should have a single, clear purpose
|
|
176
|
+
6. **Test cross-platform**: Verify behavior on both web and native platforms
|
|
177
|
+
|
|
178
|
+
## Common Patterns
|
|
179
|
+
|
|
180
|
+
### Form Dialog
|
|
181
|
+
```tsx
|
|
182
|
+
<Dialog open={formOpen} onOpenChange={setFormOpen} title="Edit Profile">
|
|
183
|
+
<View spacing="md">
|
|
184
|
+
<Input label="Name" value={name} onChangeText={setName} />
|
|
185
|
+
<Input label="Email" value={email} onChangeText={setEmail} />
|
|
186
|
+
<View style={{ flexDirection: 'row', gap: 12 }}>
|
|
187
|
+
<Button variant="outlined" onPress={() => setFormOpen(false)}>
|
|
188
|
+
Cancel
|
|
189
|
+
</Button>
|
|
190
|
+
<Button variant="contained" onPress={handleSave}>
|
|
191
|
+
Save
|
|
192
|
+
</Button>
|
|
193
|
+
</View>
|
|
194
|
+
</View>
|
|
195
|
+
</Dialog>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Loading Dialog
|
|
199
|
+
```tsx
|
|
200
|
+
<Dialog
|
|
201
|
+
open={loading}
|
|
202
|
+
onOpenChange={() => {}}
|
|
203
|
+
closeOnBackdropClick={false}
|
|
204
|
+
showCloseButton={false}
|
|
205
|
+
>
|
|
206
|
+
<View style={{ alignItems: 'center', padding: 20 }}>
|
|
207
|
+
<Text>Loading...</Text>
|
|
208
|
+
</View>
|
|
209
|
+
</Dialog>
|
|
210
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface DialogProps {
|
|
4
|
+
/**
|
|
5
|
+
* Whether the dialog is open/visible
|
|
6
|
+
*/
|
|
7
|
+
open: boolean;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Called when the dialog should be opened or closed
|
|
11
|
+
*/
|
|
12
|
+
onOpenChange: (open: boolean) => void;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Optional title for the dialog
|
|
16
|
+
*/
|
|
17
|
+
title?: string;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The content to display inside the dialog
|
|
21
|
+
*/
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The size of the dialog
|
|
26
|
+
*/
|
|
27
|
+
size?: 'small' | 'medium' | 'large' | 'fullscreen';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The visual style variant of the dialog
|
|
31
|
+
*/
|
|
32
|
+
variant?: 'default' | 'alert' | 'confirmation';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether to show the close button in the header
|
|
36
|
+
*/
|
|
37
|
+
showCloseButton?: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether clicking the backdrop should close the dialog
|
|
41
|
+
*/
|
|
42
|
+
closeOnBackdropClick?: boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Whether pressing escape key should close the dialog (web only)
|
|
46
|
+
*/
|
|
47
|
+
closeOnEscapeKey?: boolean;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Animation type for the dialog (native only)
|
|
51
|
+
*/
|
|
52
|
+
animationType?: 'slide' | 'fade' | 'none';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Additional styles (platform-specific)
|
|
56
|
+
*/
|
|
57
|
+
style?: any;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Test ID for testing
|
|
61
|
+
*/
|
|
62
|
+
testID?: string;
|
|
63
|
+
}
|
|
@@ -3,9 +3,11 @@ import { TextInput } from 'react-native';
|
|
|
3
3
|
import { InputProps } from './types';
|
|
4
4
|
import { inputStyles } from './Input.styles';
|
|
5
5
|
|
|
6
|
-
const Input
|
|
6
|
+
const Input = React.forwardRef<TextInput, InputProps>(({
|
|
7
7
|
value,
|
|
8
8
|
onChangeText,
|
|
9
|
+
onFocus,
|
|
10
|
+
onBlur,
|
|
9
11
|
placeholder,
|
|
10
12
|
disabled = false,
|
|
11
13
|
inputType = 'text',
|
|
@@ -16,7 +18,7 @@ const Input: React.FC<InputProps> = ({
|
|
|
16
18
|
hasError = false,
|
|
17
19
|
style,
|
|
18
20
|
testID,
|
|
19
|
-
}) => {
|
|
21
|
+
}, ref) => {
|
|
20
22
|
const [isFocused, setIsFocused] = useState(false);
|
|
21
23
|
|
|
22
24
|
const getKeyboardType = () => {
|
|
@@ -34,10 +36,16 @@ const Input: React.FC<InputProps> = ({
|
|
|
34
36
|
|
|
35
37
|
const handleFocus = () => {
|
|
36
38
|
setIsFocused(true);
|
|
39
|
+
if (onFocus) {
|
|
40
|
+
onFocus();
|
|
41
|
+
}
|
|
37
42
|
};
|
|
38
43
|
|
|
39
44
|
const handleBlur = () => {
|
|
40
45
|
setIsFocused(false);
|
|
46
|
+
if (onBlur) {
|
|
47
|
+
onBlur();
|
|
48
|
+
}
|
|
41
49
|
};
|
|
42
50
|
|
|
43
51
|
// Apply variants to the stylesheet
|
|
@@ -56,6 +64,7 @@ const Input: React.FC<InputProps> = ({
|
|
|
56
64
|
|
|
57
65
|
return (
|
|
58
66
|
<TextInput
|
|
67
|
+
ref={ref}
|
|
59
68
|
value={value}
|
|
60
69
|
onChangeText={onChangeText}
|
|
61
70
|
placeholder={placeholder}
|
|
@@ -70,6 +79,6 @@ const Input: React.FC<InputProps> = ({
|
|
|
70
79
|
placeholderTextColor="#999999"
|
|
71
80
|
/>
|
|
72
81
|
);
|
|
73
|
-
};
|
|
82
|
+
});
|
|
74
83
|
|
|
75
84
|
export default Input;
|
|
@@ -56,6 +56,19 @@ export const inputStyles = StyleSheet.create((theme) => ({
|
|
|
56
56
|
border: 'none',
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
|
+
bare: {
|
|
60
|
+
backgroundColor: 'transparent',
|
|
61
|
+
borderWidth: 0,
|
|
62
|
+
borderColor: 'transparent',
|
|
63
|
+
color: theme.colors?.text?.primary || '#000000',
|
|
64
|
+
paddingHorizontal: 0,
|
|
65
|
+
paddingVertical: 0,
|
|
66
|
+
|
|
67
|
+
_web: {
|
|
68
|
+
border: 'none',
|
|
69
|
+
boxShadow: 'none',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
59
72
|
},
|
|
60
73
|
focused: {
|
|
61
74
|
true: {
|
|
@@ -106,6 +119,16 @@ export const inputStyles = StyleSheet.create((theme) => ({
|
|
|
106
119
|
elevation: 0.5,
|
|
107
120
|
},
|
|
108
121
|
},
|
|
122
|
+
// Bare variant focus (no visual changes)
|
|
123
|
+
{
|
|
124
|
+
variant: 'bare',
|
|
125
|
+
focused: true,
|
|
126
|
+
styles: {
|
|
127
|
+
backgroundColor: 'transparent',
|
|
128
|
+
borderWidth: 0,
|
|
129
|
+
borderColor: 'transparent',
|
|
130
|
+
},
|
|
131
|
+
},
|
|
109
132
|
],
|
|
110
133
|
|
|
111
134
|
borderRadius: theme.borderRadius?.md || 8,
|
package/src/Input/Input.web.tsx
CHANGED
|
@@ -3,9 +3,11 @@ import { getWebProps } from 'react-native-unistyles/web';
|
|
|
3
3
|
import { InputProps } from './types';
|
|
4
4
|
import { inputStyles } from './Input.styles';
|
|
5
5
|
|
|
6
|
-
const Input
|
|
6
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
|
7
7
|
value,
|
|
8
8
|
onChangeText,
|
|
9
|
+
onFocus,
|
|
10
|
+
onBlur,
|
|
9
11
|
placeholder,
|
|
10
12
|
disabled = false,
|
|
11
13
|
inputType = 'text',
|
|
@@ -16,7 +18,8 @@ const Input: React.FC<InputProps> = ({
|
|
|
16
18
|
hasError = false,
|
|
17
19
|
style,
|
|
18
20
|
testID,
|
|
19
|
-
}) => {
|
|
21
|
+
}, ref) => {
|
|
22
|
+
|
|
20
23
|
const getInputType = () => {
|
|
21
24
|
switch (inputType) {
|
|
22
25
|
case 'email':
|
|
@@ -37,10 +40,22 @@ const Input: React.FC<InputProps> = ({
|
|
|
37
40
|
}
|
|
38
41
|
};
|
|
39
42
|
|
|
43
|
+
const handleFocus = () => {
|
|
44
|
+
if (onFocus) {
|
|
45
|
+
onFocus();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handleBlur = () => {
|
|
50
|
+
if (onBlur) {
|
|
51
|
+
onBlur();
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
40
55
|
// Apply variants using the correct Unistyles 3.0 pattern
|
|
41
56
|
inputStyles.useVariants({
|
|
42
57
|
size: size as 'small' | 'medium' | 'large',
|
|
43
|
-
variant: variant as 'default' | 'outlined' | 'filled',
|
|
58
|
+
variant: variant as 'default' | 'outlined' | 'filled' | 'bare',
|
|
44
59
|
});
|
|
45
60
|
|
|
46
61
|
// Create the style array following the official documentation pattern
|
|
@@ -51,21 +66,34 @@ const Input: React.FC<InputProps> = ({
|
|
|
51
66
|
style,
|
|
52
67
|
].filter(Boolean);
|
|
53
68
|
|
|
54
|
-
// Use getWebProps
|
|
55
|
-
const webProps = getWebProps(inputStyleArray);
|
|
69
|
+
// Use getWebProps for Unistyles, then manually add our ref
|
|
70
|
+
const { ref: unistylesRef, ...webProps } = getWebProps(inputStyleArray);
|
|
71
|
+
|
|
72
|
+
// Forward the ref while still providing unistyles with access
|
|
73
|
+
const handleRef = (r: HTMLInputElement | null) => {
|
|
74
|
+
unistylesRef.current = r;
|
|
75
|
+
if (typeof ref === 'function') {
|
|
76
|
+
ref(r);
|
|
77
|
+
} else if (ref) {
|
|
78
|
+
ref.current = r;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
56
81
|
|
|
57
82
|
return (
|
|
58
83
|
<input
|
|
59
84
|
{...webProps}
|
|
85
|
+
ref={handleRef}
|
|
60
86
|
type={secureTextEntry ? 'password' : getInputType()}
|
|
61
87
|
value={value}
|
|
62
88
|
onChange={handleChange}
|
|
89
|
+
onFocus={handleFocus}
|
|
90
|
+
onBlur={handleBlur}
|
|
63
91
|
placeholder={placeholder}
|
|
64
92
|
disabled={disabled}
|
|
65
93
|
autoCapitalize={autoCapitalize}
|
|
66
94
|
data-testid={testID}
|
|
67
95
|
/>
|
|
68
96
|
);
|
|
69
|
-
};
|
|
97
|
+
});
|
|
70
98
|
|
|
71
99
|
export default Input;
|
package/src/Input/types.ts
CHANGED
|
@@ -11,6 +11,16 @@ export interface InputProps {
|
|
|
11
11
|
*/
|
|
12
12
|
onChangeText?: (text: string) => void;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Called when the input receives focus
|
|
16
|
+
*/
|
|
17
|
+
onFocus?: () => void;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Called when the input loses focus
|
|
21
|
+
*/
|
|
22
|
+
onBlur?: () => void;
|
|
23
|
+
|
|
14
24
|
/**
|
|
15
25
|
* Placeholder text
|
|
16
26
|
*/
|
|
@@ -44,7 +54,7 @@ export interface InputProps {
|
|
|
44
54
|
/**
|
|
45
55
|
* Style variant of the input
|
|
46
56
|
*/
|
|
47
|
-
variant?: 'default' | 'outlined' | 'filled';
|
|
57
|
+
variant?: 'default' | 'outlined' | 'filled' | 'bare';
|
|
48
58
|
|
|
49
59
|
/**
|
|
50
60
|
* The intent/color scheme of the input (for focus states, validation, etc.)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { Modal, View, TouchableWithoutFeedback, BackHandler, Dimensions } from 'react-native';
|
|
3
|
+
import { PopoverProps } from './types';
|
|
4
|
+
import { popoverStyles } from './Popover.styles';
|
|
5
|
+
|
|
6
|
+
const Popover: React.FC<PopoverProps> = ({
|
|
7
|
+
open,
|
|
8
|
+
onOpenChange,
|
|
9
|
+
anchor,
|
|
10
|
+
children,
|
|
11
|
+
placement = 'bottom',
|
|
12
|
+
offset = 8,
|
|
13
|
+
closeOnClickOutside = true,
|
|
14
|
+
showArrow = false, // Arrows are complex on native, disabled by default
|
|
15
|
+
style,
|
|
16
|
+
testID,
|
|
17
|
+
}) => {
|
|
18
|
+
const popoverRef = useRef<View>(null);
|
|
19
|
+
|
|
20
|
+
// Handle Android back button
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!open) return;
|
|
23
|
+
|
|
24
|
+
const handleBackPress = () => {
|
|
25
|
+
onOpenChange(false);
|
|
26
|
+
return true;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const backHandler = BackHandler.addEventListener('hardwareBackPress', handleBackPress);
|
|
30
|
+
return () => backHandler.remove();
|
|
31
|
+
}, [open, onOpenChange]);
|
|
32
|
+
|
|
33
|
+
const handleBackdropPress = () => {
|
|
34
|
+
if (closeOnClickOutside) {
|
|
35
|
+
onOpenChange(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (!open) return null;
|
|
40
|
+
|
|
41
|
+
// For React Native, we simplify positioning - center the popover
|
|
42
|
+
// More complex anchor positioning would require measuring anchor positions
|
|
43
|
+
// which is challenging cross-platform
|
|
44
|
+
const screenDimensions = Dimensions.get('window');
|
|
45
|
+
const popoverStyle = [
|
|
46
|
+
popoverStyles.container,
|
|
47
|
+
{
|
|
48
|
+
// Center on screen as a simplified approach
|
|
49
|
+
position: 'absolute',
|
|
50
|
+
top: screenDimensions.height * 0.4,
|
|
51
|
+
left: 20,
|
|
52
|
+
right: 20,
|
|
53
|
+
maxWidth: screenDimensions.width - 40,
|
|
54
|
+
},
|
|
55
|
+
style,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Modal
|
|
60
|
+
visible={open}
|
|
61
|
+
transparent
|
|
62
|
+
animationType="fade"
|
|
63
|
+
onRequestClose={() => onOpenChange(false)}
|
|
64
|
+
testID={testID}
|
|
65
|
+
>
|
|
66
|
+
<TouchableWithoutFeedback onPress={handleBackdropPress}>
|
|
67
|
+
<View style={popoverStyles.backdrop}>
|
|
68
|
+
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
|
|
69
|
+
<View ref={popoverRef} style={popoverStyle}>
|
|
70
|
+
{showArrow && (
|
|
71
|
+
<View style={[
|
|
72
|
+
popoverStyles.arrow,
|
|
73
|
+
// Apply placement-based arrow positioning
|
|
74
|
+
]} />
|
|
75
|
+
)}
|
|
76
|
+
<View style={popoverStyles.content}>
|
|
77
|
+
{children}
|
|
78
|
+
</View>
|
|
79
|
+
</View>
|
|
80
|
+
</TouchableWithoutFeedback>
|
|
81
|
+
</View>
|
|
82
|
+
</TouchableWithoutFeedback>
|
|
83
|
+
</Modal>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export default Popover;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { StyleSheet } from 'react-native-unistyles';
|
|
2
|
+
|
|
3
|
+
export const popoverStyles = StyleSheet.create((theme) => ({
|
|
4
|
+
container: {
|
|
5
|
+
backgroundColor: theme.colors?.surface?.primary || '#ffffff',
|
|
6
|
+
borderRadius: theme.borderRadius?.md || 8,
|
|
7
|
+
border: `1px solid ${theme.colors?.border?.primary || '#e5e7eb'}`,
|
|
8
|
+
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
|
9
|
+
transition: 'opacity 150ms ease-out, transform 150ms ease-out',
|
|
10
|
+
transformOrigin: 'center center',
|
|
11
|
+
maxWidth: 320,
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
content: {
|
|
15
|
+
padding: theme.spacing?.md || 12,
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
arrow: {
|
|
19
|
+
position: 'absolute',
|
|
20
|
+
width: 12,
|
|
21
|
+
height: 12,
|
|
22
|
+
backgroundColor: theme.colors?.surface?.primary || '#ffffff',
|
|
23
|
+
transform: 'rotate(45deg)',
|
|
24
|
+
|
|
25
|
+
variants: {
|
|
26
|
+
placement: {
|
|
27
|
+
top: {
|
|
28
|
+
bottom: -6,
|
|
29
|
+
left: '50%',
|
|
30
|
+
marginLeft: -6,
|
|
31
|
+
},
|
|
32
|
+
'top-start': {
|
|
33
|
+
bottom: -6,
|
|
34
|
+
left: 16,
|
|
35
|
+
},
|
|
36
|
+
'top-end': {
|
|
37
|
+
bottom: -6,
|
|
38
|
+
right: 16,
|
|
39
|
+
},
|
|
40
|
+
bottom: {
|
|
41
|
+
top: -6,
|
|
42
|
+
left: '50%',
|
|
43
|
+
marginLeft: -6,
|
|
44
|
+
},
|
|
45
|
+
'bottom-start': {
|
|
46
|
+
top: -6,
|
|
47
|
+
left: 16,
|
|
48
|
+
},
|
|
49
|
+
'bottom-end': {
|
|
50
|
+
top: -6,
|
|
51
|
+
right: 16,
|
|
52
|
+
},
|
|
53
|
+
left: {
|
|
54
|
+
right: -6,
|
|
55
|
+
top: '50%',
|
|
56
|
+
marginTop: -6,
|
|
57
|
+
},
|
|
58
|
+
'left-start': {
|
|
59
|
+
right: -6,
|
|
60
|
+
top: 16,
|
|
61
|
+
},
|
|
62
|
+
'left-end': {
|
|
63
|
+
right: -6,
|
|
64
|
+
bottom: 16,
|
|
65
|
+
},
|
|
66
|
+
right: {
|
|
67
|
+
left: -6,
|
|
68
|
+
top: '50%',
|
|
69
|
+
marginTop: -6,
|
|
70
|
+
},
|
|
71
|
+
'right-start': {
|
|
72
|
+
left: -6,
|
|
73
|
+
top: 16,
|
|
74
|
+
},
|
|
75
|
+
'right-end': {
|
|
76
|
+
left: -6,
|
|
77
|
+
bottom: 16,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
_web: {
|
|
83
|
+
boxShadow: '-2px 2px 4px rgba(0, 0, 0, 0.1)',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// Native-specific backdrop
|
|
88
|
+
backdrop: {
|
|
89
|
+
position: 'absolute',
|
|
90
|
+
top: 0,
|
|
91
|
+
left: 0,
|
|
92
|
+
right: 0,
|
|
93
|
+
bottom: 0,
|
|
94
|
+
backgroundColor: 'transparent',
|
|
95
|
+
},
|
|
96
|
+
}));
|