@mrmeg/expo-ui 0.7.0 → 0.7.2
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/LLM_USAGE.md +5 -1
- package/README.md +5 -1
- package/dist/components/BottomSheet.js +38 -3
- package/dist/components/BottomSheetKeyboard.js +6 -2
- package/dist/components/Notification.js +58 -4
- package/dist/state/globalUIStore.d.ts +23 -16
- package/llms-full.md +1 -1
- package/package.json +10 -4
package/LLM_USAGE.md
CHANGED
|
@@ -184,7 +184,7 @@ Use this table before creating a new app-local primitive.
|
|
|
184
184
|
| `InputOTP` | Verification code entry | Multiple manually managed text inputs | Email codes, SMS codes, MFA, invite codes |
|
|
185
185
|
| `Label` | Accessible form labels | Plain styled text labels | Required labels, disabled labels, field group labels |
|
|
186
186
|
| `MaxWidthContainer` | Centered responsive width | Per-screen max-width wrappers | Web pages, tablet layouts, settings forms |
|
|
187
|
-
| `Notification` | Global toast surface | Screen-local toast state | Saved/error/sync notifications, bottom-position alerts |
|
|
187
|
+
| `Notification` | Global toast surface | Screen-local toast state | Saved/error/sync notifications, action toasts, bottom-position alerts |
|
|
188
188
|
| `Popover` | Anchored contextual content | Custom anchored views | Inline help, quick previews, contextual controls |
|
|
189
189
|
| `Progress` | Determinate or indeterminate progress | Layout-shifting spinners for progress regions | Upload progress, onboarding completion |
|
|
190
190
|
| `RadioGroup`, `RadioGroupItem` | Mutually exclusive choices | Custom radio rows | Plan interval, visibility choice, survey answer |
|
|
@@ -259,5 +259,9 @@ globalUIStore.getState().show({
|
|
|
259
259
|
type: "success",
|
|
260
260
|
title: "Saved",
|
|
261
261
|
messages: ["Your changes were saved."],
|
|
262
|
+
action: {
|
|
263
|
+
label: "View",
|
|
264
|
+
onPress: openSavedItem,
|
|
265
|
+
},
|
|
262
266
|
});
|
|
263
267
|
```
|
package/README.md
CHANGED
|
@@ -224,7 +224,7 @@ All components are exported from `@mrmeg/expo-ui/components`; direct imports suc
|
|
|
224
224
|
| `InputOTP` | Verification code entry | Multiple manually managed text inputs | Email codes, SMS codes, MFA, invite codes |
|
|
225
225
|
| `Label` | Accessible form labels | Plain styled text labels | Required labels, disabled labels, field group labels |
|
|
226
226
|
| `MaxWidthContainer` | Centered responsive width | Per-screen max-width wrappers | Web pages, tablet layouts, settings forms, auth panels |
|
|
227
|
-
| `Notification` | Global toast surface | Screen-local toast state | Saved/error/sync notifications, loading toast, bottom-position alerts |
|
|
227
|
+
| `Notification` | Global toast surface | Screen-local toast state | Saved/error/sync notifications, action toasts, loading toast, bottom-position alerts |
|
|
228
228
|
| `Popover` | Anchored contextual content | Custom anchored views | Inline help, quick previews, contextual controls, small forms |
|
|
229
229
|
| `Progress` | Determinate or indeterminate progress | Layout-shifting spinners for progress regions | Upload progress, onboarding completion, long-running task state |
|
|
230
230
|
| `RadioGroup`, `RadioGroupItem` | Mutually exclusive choices | Custom radio rows | Plan interval, visibility choice, survey answer, preference setting |
|
|
@@ -332,6 +332,10 @@ globalUIStore.getState().show({
|
|
|
332
332
|
type: "success",
|
|
333
333
|
title: "Saved",
|
|
334
334
|
messages: ["Your changes were saved."],
|
|
335
|
+
action: {
|
|
336
|
+
label: "View",
|
|
337
|
+
onPress: openSavedItem,
|
|
338
|
+
},
|
|
335
339
|
});
|
|
336
340
|
```
|
|
337
341
|
|
|
@@ -40,6 +40,9 @@ import { BottomSheetKeyboardController, useBottomSheetKeyboardAnimation, } from
|
|
|
40
40
|
*/
|
|
41
41
|
// Platform-specific overlay wrapper
|
|
42
42
|
const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
|
|
43
|
+
// Floor for the keyboard-avoiding height shrink, so a tall keyboard on a short
|
|
44
|
+
// screen can't collapse the sheet to nothing.
|
|
45
|
+
const MIN_KEYBOARD_SHEET_HEIGHT = 220;
|
|
43
46
|
// ============================================================================
|
|
44
47
|
// Context
|
|
45
48
|
// ============================================================================
|
|
@@ -52,19 +55,51 @@ function useBottomSheetContext() {
|
|
|
52
55
|
return context;
|
|
53
56
|
}
|
|
54
57
|
const DragContext = createContext(null);
|
|
55
|
-
function BottomSheetPanel({ accessibilityViewIsModal, children, panHandlers, sheetStyle, styleOverride, translateY, ...props }) {
|
|
58
|
+
function BottomSheetPanel({ accessibilityViewIsModal, animatedHeight, animatedBottom, children, panHandlers, sheetStyle, styleOverride, translateY, ...props }) {
|
|
56
59
|
return (_jsx(Animated.View, { style: [
|
|
57
60
|
sheetStyle,
|
|
61
|
+
// Layout overrides (JS-driven) must come after sheetStyle so they win.
|
|
62
|
+
animatedBottom ? { bottom: animatedBottom } : undefined,
|
|
63
|
+
animatedHeight ? { height: animatedHeight } : undefined,
|
|
64
|
+
// Open/close + drag (native-driven) live on transform, a separate node.
|
|
58
65
|
{ transform: [{ translateY }] },
|
|
59
66
|
styleOverride && typeof styleOverride !== "function"
|
|
60
67
|
? StyleSheet.flatten(styleOverride)
|
|
61
68
|
: undefined,
|
|
62
69
|
], accessibilityViewIsModal: accessibilityViewIsModal, ...panHandlers, ...props, children: children }));
|
|
63
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Lifts the sheet above the keyboard while keeping its top edge on-screen.
|
|
73
|
+
*
|
|
74
|
+
* The naive approach translates the whole rigid box up by the keyboard height,
|
|
75
|
+
* which shoves the header (and any inputs near the top) off the top of the
|
|
76
|
+
* screen on tall sheets. Instead we lift the sheet's bottom to sit just above
|
|
77
|
+
* the keyboard and shrink its height by the same amount, so the top edge holds
|
|
78
|
+
* steady. The flex Body soaks up the lost height and scrolls, leaving the
|
|
79
|
+
* header, focused input, and footer all visible.
|
|
80
|
+
*
|
|
81
|
+
* This drives layout props (`bottom`/`height`) with a JS-driven keyboard value,
|
|
82
|
+
* intentionally separate from the native-driven open/close `translateY` — a
|
|
83
|
+
* single Animated.Value can't feed both a native transform and a JS layout
|
|
84
|
+
* prop, but distinct nodes on the same view can.
|
|
85
|
+
*/
|
|
64
86
|
function KeyboardAvoidingBottomSheetPanel(props) {
|
|
65
87
|
const { height: keyboardHeight } = useBottomSheetKeyboardAnimation();
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
// sheetStyle.height is the resolved max snap height (a number, set by Content).
|
|
89
|
+
const baseHeight = typeof props.sheetStyle.height === "number" ? props.sheetStyle.height : undefined;
|
|
90
|
+
const animatedHeight = useMemo(() => {
|
|
91
|
+
if (baseHeight === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
// keyboardHeight: 0 (closed) → keyboard px (open). Shrink the sheet by it,
|
|
94
|
+
// clamped to a usable minimum so it never collapses on short screens.
|
|
95
|
+
const shrunk = Animated.subtract(baseHeight, keyboardHeight);
|
|
96
|
+
return shrunk.interpolate({
|
|
97
|
+
inputRange: [MIN_KEYBOARD_SHEET_HEIGHT, baseHeight],
|
|
98
|
+
outputRange: [MIN_KEYBOARD_SHEET_HEIGHT, baseHeight],
|
|
99
|
+
extrapolate: "clamp",
|
|
100
|
+
});
|
|
101
|
+
}, [baseHeight, keyboardHeight]);
|
|
102
|
+
return (_jsx(BottomSheetPanel, { ...props, animatedHeight: animatedHeight, animatedBottom: keyboardHeight }));
|
|
68
103
|
}
|
|
69
104
|
// ============================================================================
|
|
70
105
|
// Utility Functions
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import { Animated, Keyboard, Platform } from "react-native";
|
|
3
|
+
// This value drives layout props (a sheet's `bottom`/`height`), which the
|
|
4
|
+
// native animation driver can't touch — so timings here must stay on the JS
|
|
5
|
+
// driver. It also means the value is a positive keyboard height (0 when
|
|
6
|
+
// hidden), letting consumers both lift and shrink the sheet from one source.
|
|
3
7
|
function animateKeyboardOffset(value, toValue, duration = 180) {
|
|
4
8
|
Animated.timing(value, {
|
|
5
9
|
toValue,
|
|
6
10
|
duration,
|
|
7
|
-
useNativeDriver:
|
|
11
|
+
useNativeDriver: false,
|
|
8
12
|
}).start();
|
|
9
13
|
}
|
|
10
14
|
export function useBottomSheetKeyboardAnimation() {
|
|
@@ -16,7 +20,7 @@ export function useBottomSheetKeyboardAnimation() {
|
|
|
16
20
|
const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
|
|
17
21
|
const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
|
|
18
22
|
const showSubscription = Keyboard.addListener(showEvent, (event) => {
|
|
19
|
-
animateKeyboardOffset(keyboardHeight,
|
|
23
|
+
animateKeyboardOffset(keyboardHeight, event.endCoordinates.height, event.duration || 180);
|
|
20
24
|
});
|
|
21
25
|
const hideSubscription = Keyboard.addListener(hideEvent, (event) => {
|
|
22
26
|
animateKeyboardOffset(keyboardHeight, 0, event.duration || 160);
|
|
@@ -46,14 +46,29 @@ export const Notification = () => {
|
|
|
46
46
|
const position = alert?.position ?? "top";
|
|
47
47
|
const isBottom = position === "bottom";
|
|
48
48
|
// Just opacity + translateY — no scale (scale = bouncy feel)
|
|
49
|
-
const
|
|
50
|
-
|
|
49
|
+
const opacityRef = useRef(null);
|
|
50
|
+
if (opacityRef.current === null) {
|
|
51
|
+
opacityRef.current = new Animated.Value(0);
|
|
52
|
+
}
|
|
53
|
+
const opacity = opacityRef.current;
|
|
54
|
+
const translateYRef = useRef(null);
|
|
55
|
+
if (translateYRef.current === null) {
|
|
56
|
+
translateYRef.current = new Animated.Value(0);
|
|
57
|
+
}
|
|
58
|
+
const translateY = translateYRef.current;
|
|
51
59
|
const wasVisibleRef = useRef(false);
|
|
52
60
|
const timerRef = useRef(null);
|
|
53
61
|
const hideNotification = useCallback(() => {
|
|
54
62
|
hide();
|
|
55
63
|
}, [hide]);
|
|
64
|
+
const clearAutoDismissTimer = useCallback(() => {
|
|
65
|
+
if (timerRef.current) {
|
|
66
|
+
clearTimeout(timerRef.current);
|
|
67
|
+
timerRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
56
70
|
const animateOut = useCallback(() => {
|
|
71
|
+
clearAutoDismissTimer();
|
|
57
72
|
if (reduceMotion) {
|
|
58
73
|
opacity.setValue(0);
|
|
59
74
|
hideNotification();
|
|
@@ -73,7 +88,18 @@ export const Notification = () => {
|
|
|
73
88
|
if (finished)
|
|
74
89
|
hideNotification();
|
|
75
90
|
});
|
|
76
|
-
}, [reduceMotion, isBottom, opacity, translateY, hideNotification]);
|
|
91
|
+
}, [clearAutoDismissTimer, reduceMotion, isBottom, opacity, translateY, hideNotification]);
|
|
92
|
+
const handleActionPress = useCallback(() => {
|
|
93
|
+
const action = alert?.action;
|
|
94
|
+
if (!action)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
action.onPress();
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
animateOut();
|
|
101
|
+
}
|
|
102
|
+
}, [alert?.action, animateOut]);
|
|
77
103
|
// The auto-dismiss timer only needs the latest animateOut; wrapping it in an
|
|
78
104
|
// Effect Event keeps it out of the deps so the effect doesn't re-run (and
|
|
79
105
|
// restart the timer) every time animateOut's identity changes.
|
|
@@ -180,6 +206,7 @@ export const Notification = () => {
|
|
|
180
206
|
const message = alert?.messages?.find((item) => item.trim().length > 0);
|
|
181
207
|
const title = getTitle(message);
|
|
182
208
|
const hasMessage = !!message;
|
|
209
|
+
const action = alert?.action;
|
|
183
210
|
if (!alert?.show) {
|
|
184
211
|
return null;
|
|
185
212
|
}
|
|
@@ -195,7 +222,14 @@ export const Notification = () => {
|
|
|
195
222
|
styles.alert,
|
|
196
223
|
isBottom && styles.alertBottom,
|
|
197
224
|
getShadowStyle("base"),
|
|
198
|
-
], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false, style: [styles.alertTitle, { color: theme.colors.foreground }], numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, style: [styles.alertDescription, { color: theme.colors.mutedForeground }], numberOfLines: 2, children: message }))] }), _jsx(Pressable, { style:
|
|
225
|
+
], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false, style: [styles.alertTitle, { color: theme.colors.foreground }], numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, style: [styles.alertDescription, { color: theme.colors.mutedForeground }], numberOfLines: 2, children: message }))] }), action && (_jsx(Pressable, { style: ({ pressed }) => [
|
|
226
|
+
styles.actionButton,
|
|
227
|
+
{
|
|
228
|
+
borderColor: theme.colors.primary + "30",
|
|
229
|
+
backgroundColor: theme.colors.primary + "10",
|
|
230
|
+
},
|
|
231
|
+
pressed && styles.actionButtonPressed,
|
|
232
|
+
], hitSlop: spacing.xs, onPress: handleActionPress, accessibilityLabel: action.label, accessibilityRole: "button", children: _jsx(StyledText, { selectable: false, style: [styles.actionLabel, { color: theme.colors.primary }], numberOfLines: 1, children: action.label }) })), _jsx(Pressable, { style: styles.closeButton, hitSlop: spacing.sm, onPress: animateOut, accessibilityLabel: "Dismiss notification", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: 16, color: theme.colors.mutedForeground }) })] }) }));
|
|
199
233
|
};
|
|
200
234
|
const createStyles = (theme) => StyleSheet.create({
|
|
201
235
|
container: {
|
|
@@ -248,6 +282,26 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
248
282
|
fontSize: 13,
|
|
249
283
|
lineHeight: 18,
|
|
250
284
|
},
|
|
285
|
+
actionButton: {
|
|
286
|
+
minHeight: 28,
|
|
287
|
+
maxWidth: 140,
|
|
288
|
+
paddingHorizontal: spacing.sm,
|
|
289
|
+
borderRadius: spacing.radiusSm,
|
|
290
|
+
borderWidth: 1,
|
|
291
|
+
justifyContent: "center",
|
|
292
|
+
alignItems: "center",
|
|
293
|
+
flexShrink: 0,
|
|
294
|
+
...(Platform.OS === "web" && { cursor: "pointer" }),
|
|
295
|
+
},
|
|
296
|
+
actionButtonPressed: {
|
|
297
|
+
opacity: 0.75,
|
|
298
|
+
},
|
|
299
|
+
actionLabel: {
|
|
300
|
+
fontFamily: fontFamilies.sansSerif.regular,
|
|
301
|
+
fontWeight: "600",
|
|
302
|
+
fontSize: 13,
|
|
303
|
+
lineHeight: 18,
|
|
304
|
+
},
|
|
251
305
|
closeButton: {
|
|
252
306
|
position: "absolute",
|
|
253
307
|
top: spacing.sm,
|
|
@@ -5,26 +5,33 @@
|
|
|
5
5
|
* Primarily used to trigger and dismiss the `Notification` component globally.
|
|
6
6
|
*
|
|
7
7
|
* Methods:
|
|
8
|
-
* - show({ type, title, messages, duration, loading }): displays a notification
|
|
8
|
+
* - show({ type, title, messages, duration, loading, action }): displays a notification
|
|
9
9
|
* - hide(): hides the current notification
|
|
10
10
|
*
|
|
11
11
|
* Recommended: wrap in hooks or utility functions for cleaner usage across components.
|
|
12
12
|
*/
|
|
13
|
-
type
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
title?: string;
|
|
19
|
-
duration?: number;
|
|
20
|
-
loading?: boolean;
|
|
21
|
-
/** Where to display the notification */
|
|
22
|
-
position?: "top" | "bottom";
|
|
23
|
-
} | null;
|
|
13
|
+
export type GlobalNotificationType = "error" | "success" | "info" | "warning";
|
|
14
|
+
export type GlobalNotificationPosition = "top" | "bottom";
|
|
15
|
+
export type GlobalNotificationAction = {
|
|
16
|
+
label: string;
|
|
17
|
+
onPress: () => void;
|
|
24
18
|
};
|
|
25
|
-
type
|
|
26
|
-
show:
|
|
19
|
+
export type GlobalNotificationAlert = {
|
|
20
|
+
show: boolean;
|
|
21
|
+
type: GlobalNotificationType;
|
|
22
|
+
title?: string;
|
|
23
|
+
messages?: string[];
|
|
24
|
+
duration?: number;
|
|
25
|
+
loading?: boolean;
|
|
26
|
+
/** Where to display the notification */
|
|
27
|
+
position?: GlobalNotificationPosition;
|
|
28
|
+
action?: GlobalNotificationAction;
|
|
29
|
+
};
|
|
30
|
+
export type GlobalUIState = {
|
|
31
|
+
alert: GlobalNotificationAlert | null;
|
|
32
|
+
};
|
|
33
|
+
export type GlobalUIActions = {
|
|
34
|
+
show: (alert: Omit<GlobalNotificationAlert, "show">) => void;
|
|
27
35
|
hide: () => void;
|
|
28
36
|
};
|
|
29
|
-
export declare const globalUIStore: import("zustand").UseBoundStore<import("zustand").StoreApi<
|
|
30
|
-
export {};
|
|
37
|
+
export declare const globalUIStore: import("zustand").UseBoundStore<import("zustand").StoreApi<GlobalUIState & GlobalUIActions>>;
|
package/llms-full.md
CHANGED
|
@@ -101,7 +101,7 @@ Use this catalog before creating a new app-local primitive.
|
|
|
101
101
|
| `InputOTP` | `@mrmeg/expo-ui/components` | Verification code entry | Prefer over manually managed text input groups. |
|
|
102
102
|
| `Label` | `@mrmeg/expo-ui/components` | Accessible form labels | Use with package form controls. |
|
|
103
103
|
| `MaxWidthContainer` | `@mrmeg/expo-ui/components` | Centered responsive width | Use for web and tablet constrained layouts. |
|
|
104
|
-
| `Notification` | `@mrmeg/expo-ui/components` | Global toast surface | Trigger through `globalUIStore` with root `UIProvider
|
|
104
|
+
| `Notification` | `@mrmeg/expo-ui/components` | Global toast surface | Trigger through `globalUIStore` with root `UIProvider`; optional actions dismiss after press. |
|
|
105
105
|
| `Popover` | `@mrmeg/expo-ui/components` | Anchored contextual content | Requires root `UIProvider` portal setup. |
|
|
106
106
|
| `Progress` | `@mrmeg/expo-ui/components` | Determinate or indeterminate progress | Prefer over layout-shifting spinners for progress regions. |
|
|
107
107
|
| `RadioGroup` | `@mrmeg/expo-ui/components` | Small mutually exclusive choices | Use `Select` for longer option sets. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrmeg/expo-ui",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
|
|
6
6
|
"keywords": [
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"homepage": "https://github.com/mrmeg/expo-template/tree/main/packages/ui",
|
|
25
25
|
"type": "module",
|
|
26
26
|
"publishConfig": {
|
|
27
|
-
"access": "public"
|
|
27
|
+
"access": "public",
|
|
28
|
+
"registry": "https://registry.npmjs.org/"
|
|
28
29
|
},
|
|
29
30
|
"main": "./dist/index.js",
|
|
30
31
|
"types": "./dist/index.d.ts",
|
|
@@ -69,6 +70,10 @@
|
|
|
69
70
|
"types": "./dist/state/index.d.ts",
|
|
70
71
|
"default": "./dist/state/index.js"
|
|
71
72
|
},
|
|
73
|
+
"./state/*": {
|
|
74
|
+
"types": "./dist/state/*.d.ts",
|
|
75
|
+
"default": "./dist/state/*.js"
|
|
76
|
+
},
|
|
72
77
|
"./lib": {
|
|
73
78
|
"types": "./dist/lib/index.d.ts",
|
|
74
79
|
"default": "./dist/lib/index.js"
|
|
@@ -76,8 +81,9 @@
|
|
|
76
81
|
},
|
|
77
82
|
"scripts": {
|
|
78
83
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
79
|
-
"test": "jest --config ../../jest.config.js packages/ui/src --runInBand --watchman=false",
|
|
80
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-ui-package-esm.mjs",
|
|
84
|
+
"test": "jest --config ../../jest.config.js packages/ui/src --runInBand --watchman=false && bun run check:forbidden-imports",
|
|
85
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-ui-package-esm.mjs && bun run check:forbidden-imports",
|
|
86
|
+
"check:forbidden-imports": "node ../../scripts/check-ui-forbidden-imports.mjs",
|
|
81
87
|
"publish:dry-run": "bun pm pack --dry-run"
|
|
82
88
|
},
|
|
83
89
|
"dependencies": {
|