@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 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
- const composedTranslateY = useMemo(() => Animated.add(props.translateY, keyboardHeight), [keyboardHeight, props.translateY]);
67
- return _jsx(BottomSheetPanel, { ...props, translateY: composedTranslateY });
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: true,
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, -event.endCoordinates.height, event.duration || 180);
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 opacity = useRef(new Animated.Value(0)).current;
50
- const translateY = useRef(new Animated.Value(0)).current;
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: styles.closeButton, hitSlop: spacing.sm, onPress: animateOut, accessibilityLabel: "Dismiss notification", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: 16, color: theme.colors.mutedForeground }) })] }) }));
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 State = {
14
- alert: {
15
- show: boolean;
16
- type: "error" | "success" | "info" | "warning";
17
- messages?: string[];
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 Actions = {
26
- show: (alert: Omit<NonNullable<State["alert"]>, "show">) => void;
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<State & Actions>>;
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.0",
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": {