@mrmeg/expo-ui 0.7.2 → 0.8.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.
Files changed (59) hide show
  1. package/LLM_USAGE.md +21 -11
  2. package/README.md +8 -10
  3. package/dist/components/Accordion.d.ts +4 -4
  4. package/dist/components/AnimatedView.d.ts +1 -1
  5. package/dist/components/Badge.d.ts +1 -1
  6. package/dist/components/BottomSheet.d.ts +96 -20
  7. package/dist/components/BottomSheet.js +203 -444
  8. package/dist/components/Button.d.ts +3 -3
  9. package/dist/components/Button.js +17 -1
  10. package/dist/components/Card.d.ts +6 -6
  11. package/dist/components/Checkbox.d.ts +2 -1
  12. package/dist/components/Collapsible.d.ts +4 -3
  13. package/dist/components/Dialog.d.ts +10 -10
  14. package/dist/components/Dialog.js +16 -8
  15. package/dist/components/DismissKeyboard.d.ts +1 -1
  16. package/dist/components/Drawer.d.ts +7 -7
  17. package/dist/components/DropdownMenu.d.ts +10 -10
  18. package/dist/components/EmptyState.d.ts +1 -1
  19. package/dist/components/ErrorBoundary.d.ts +1 -1
  20. package/dist/components/Icon.d.ts +1 -1
  21. package/dist/components/InputOTP.d.ts +2 -1
  22. package/dist/components/Label.d.ts +1 -1
  23. package/dist/components/MaxWidthContainer.d.ts +1 -1
  24. package/dist/components/Notification.d.ts +4 -10
  25. package/dist/components/Notification.js +12 -13
  26. package/dist/components/Popover.d.ts +4 -4
  27. package/dist/components/Progress.d.ts +2 -1
  28. package/dist/components/RadioGroup.d.ts +3 -2
  29. package/dist/components/SegmentedControl.d.ts +53 -0
  30. package/dist/components/SegmentedControl.js +25 -0
  31. package/dist/components/Select.d.ts +7 -7
  32. package/dist/components/Separator.d.ts +2 -1
  33. package/dist/components/Skeleton.d.ts +5 -4
  34. package/dist/components/Slider.d.ts +24 -3
  35. package/dist/components/Slider.js +26 -147
  36. package/dist/components/StatusBar.d.ts +1 -1
  37. package/dist/components/StyledText.d.ts +12 -12
  38. package/dist/components/Switch.d.ts +2 -1
  39. package/dist/components/Tabs.d.ts +5 -5
  40. package/dist/components/Tabs.js +10 -2
  41. package/dist/components/TextInput.d.ts +1 -1
  42. package/dist/components/TextInput.js +129 -2
  43. package/dist/components/Toggle.d.ts +3 -2
  44. package/dist/components/ToggleGroup.d.ts +4 -3
  45. package/dist/components/Tooltip.d.ts +3 -3
  46. package/dist/components/UIProvider.d.ts +1 -1
  47. package/dist/components/index.d.ts +1 -0
  48. package/dist/components/index.js +1 -0
  49. package/dist/state/globalUIStore.d.ts +9 -1
  50. package/dist/state/globalUIStore.js +9 -1
  51. package/dist/state/index.d.ts +1 -0
  52. package/dist/state/index.js +1 -0
  53. package/dist/state/notify.d.ts +50 -0
  54. package/dist/state/notify.js +31 -0
  55. package/dist/state/themeColorScope.d.ts +1 -1
  56. package/llms-full.md +34 -3
  57. package/package.json +3 -2
  58. package/dist/components/BottomSheetKeyboard.d.ts +0 -7
  59. package/dist/components/BottomSheetKeyboard.js +0 -39
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useMemo, useState } from "react";
2
+ import { useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
3
  import { StyleSheet, TextInput as RNTextInput, Platform, View, Pressable, } from "react-native";
4
+ import { Host, TextInput as ExpoTextInput, useNativeState, } from "@expo/ui";
4
5
  import { useTheme } from "../hooks/useTheme.js";
5
6
  import { spacing } from "../constants/spacing.js";
6
7
  import { fontFamilies } from "../constants/fonts.js";
@@ -70,7 +71,21 @@ const SIZE_CONFIGS = {
70
71
  * />
71
72
  * ```
72
73
  */
73
- export function TextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ref, ...rest }) {
74
+ export function TextInput(props) {
75
+ // On iOS/Android, route to the native @expo/ui field for flicker-free,
76
+ // platform-native text editing. Web keeps the full-featured RN implementation
77
+ // (no flicker problem there, and it preserves every in-field affordance).
78
+ if (Platform.OS !== "web") {
79
+ return _jsx(NativeTextInput, { ...props });
80
+ }
81
+ return _jsx(WebTextInput, { ...props });
82
+ }
83
+ /**
84
+ * Web / fallback implementation — the original React Native TextInput with the
85
+ * complete chrome (variants, sizes, overlays, password toggle, clear button,
86
+ * error icon). Unchanged from the pre-native version.
87
+ */
88
+ function WebTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, showSecureEntryToggle, leftElement, rightElement, clearable = false, wrapperStyle, focusedStyle, forceLight, secureTextEntry, inputMode, style, onChangeText, onFocus, onBlur, value, multiline, editable = true, ref, ...rest }) {
74
89
  const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
75
90
  const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
76
91
  const [focused, setFocused] = useState(false);
@@ -157,7 +172,119 @@ export function TextInput({ variant = "outline", size = "md", label, helperText,
157
172
  hasError && styles.errorText,
158
173
  ], children: errorText || helperText }))] }));
159
174
  }
175
+ /**
176
+ * Native (iOS / Android) implementation backed by `@expo/ui`'s TextInput, which
177
+ * bridges to SwiftUI's `TextField`/`SecureField` and Jetpack Compose's
178
+ * `TextField`. The text buffer lives natively (via `useNativeState`), so typing
179
+ * never round-trips through React state — eliminating the cursor flicker seen on
180
+ * controlled RN inputs.
181
+ *
182
+ * By design (reliability over feature-parity) this path renders the field plus
183
+ * sibling label / helper / error text only. The in-field overlays from the web
184
+ * implementation — password visibility toggle, clear button, left/right
185
+ * elements, and error icon — are intentionally omitted on native to avoid
186
+ * layering RN views over the native host view.
187
+ */
188
+ function NativeTextInput({ variant = "outline", size = "md", label, helperText, errorText, error, required, rows, forceLight, inputMode, onChangeText, value, defaultValue, editable = true, multiline, secureTextEntry, showSecureEntryToggle, ref, wrapperStyle,
189
+ // Web-only affordances. Destructured out of `rest` so they're NOT forwarded to
190
+ // the native field; intentionally unused on this path (see doc comment above).
191
+ // `style` (an RN TextStyle) is likewise dropped — it doesn't map to
192
+ // UniversalStyle and is replaced by the `boxStyle`/`textStyle` computed below.
193
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
194
+ leftElement, rightElement, clearable, focusedStyle, style, ...rest }) {
195
+ const { theme, getContrastingColor } = useTheme();
196
+ const styles = useMemo(() => createStyles(theme, variant, size), [theme, variant, size]);
197
+ const [passwordVisible, setPasswordVisible] = useState(false);
198
+ const hasError = error || !!errorText;
199
+ const sizeConfig = SIZE_CONFIGS[size];
200
+ // Password visibility toggle. Flipping `secureTextEntry` swaps SwiftUI's
201
+ // SecureField <-> TextField on iOS and toggles Compose's visualTransformation
202
+ // on Android; both bind the same `state` observable, so the text survives.
203
+ const hasSecureToggle = !!(secureTextEntry && showSecureEntryToggle);
204
+ const effectiveSecureTextEntry = secureTextEntry && !passwordVisible;
205
+ // Native text buffer. Seeded once; `value` changes are reconciled below.
206
+ const state = useNativeState(value ?? defaultValue ?? "");
207
+ // Reconcile controlled `value` -> native buffer WITHOUT echoing keystrokes.
208
+ // Only write when the parent's value genuinely diverges (resets, clears,
209
+ // programmatic sets); typing already updated `state` natively.
210
+ useEffect(() => {
211
+ if (value !== undefined && value !== state.value) {
212
+ state.value = value;
213
+ }
214
+ }, [value, state]);
215
+ // The inner ref is the @expo/ui handle; the outward ref is typed as
216
+ // RNTextInput because that's what the public TextInputCustomProps declares.
217
+ // We expose the subset consumers use, plus a `setNativeProps` shim so the
218
+ // uncontrolled AuthTextField can push corrected text into the native buffer.
219
+ const innerRef = useRef(null);
220
+ useImperativeHandle(ref, () => ({
221
+ focus: () => innerRef.current?.focus(),
222
+ blur: () => innerRef.current?.blur(),
223
+ clear: () => {
224
+ state.value = "";
225
+ },
226
+ isFocused: () => innerRef.current?.isFocused() ?? false,
227
+ setNativeProps: (props) => {
228
+ if (typeof props?.text === "string") {
229
+ state.value = props.text;
230
+ }
231
+ },
232
+ }), [state]);
233
+ const backgroundColor = forceLight
234
+ ? palette.white
235
+ : variant === "filled"
236
+ ? theme.colors.card
237
+ : "transparent";
238
+ const borderColor = hasError
239
+ ? theme.colors.destructive
240
+ : forceLight
241
+ ? "#d1d5db"
242
+ : theme.colors.input;
243
+ const textColor = forceLight
244
+ ? "#1f2937"
245
+ : getContrastingColor(backgroundColor === "transparent" ? theme.colors.background : backgroundColor, theme.colors.text, palette.white);
246
+ // Map variant/size to @expo/ui's UniversalStyle (translated to SwiftUI /
247
+ // Compose modifiers natively).
248
+ const boxStyle = {
249
+ backgroundColor,
250
+ borderColor,
251
+ borderRadius: variant === "underlined" ? 0 : spacing.radiusMd,
252
+ borderWidth: variant === "outline" ? 1 : 0,
253
+ paddingHorizontal: sizeConfig.paddingHorizontal,
254
+ paddingVertical: sizeConfig.paddingVertical,
255
+ opacity: editable === false ? 0.6 : 1,
256
+ ...(multiline ? null : { height: sizeConfig.height }),
257
+ };
258
+ // "System" is an RN-only sentinel (RCTFont resolves it to the system font).
259
+ // @expo/ui passes the family verbatim to SwiftUI's Font.custom / Compose,
260
+ // where no such font exists — the fallback ignores fontSize and renders at
261
+ // the 17pt default, blowing up text and secure-entry dots. Omit the family
262
+ // so the native side uses the system font at our requested size.
263
+ const nativeFontFamily = fontFamilies.sansSerif.regular === "System"
264
+ ? undefined
265
+ : fontFamilies.sansSerif.regular;
266
+ const textStyle = {
267
+ color: textColor,
268
+ fontSize: sizeConfig.fontSize,
269
+ ...(nativeFontFamily ? { fontFamily: nativeFontFamily } : null),
270
+ };
271
+ return (_jsxs(View, { style: wrapperStyle, children: [!!label && (_jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: styles.label, children: [label, required && _jsx(StyledText, { selectable: false, style: styles.required, children: " *" })] }) })), _jsxs(View, { style: hasSecureToggle ? styles.nativeRow : undefined, children: [_jsx(Host, { matchContents: { vertical: true }, style: hasSecureToggle ? styles.nativeHostFlex : styles.nativeHost, children: _jsx(ExpoTextInput, { ...rest, ref: innerRef, value: state, defaultValue: defaultValue, onChangeText: onChangeText, editable: editable, multiline: multiline, rows: rows, inputMode: inputMode, secureTextEntry: effectiveSecureTextEntry, placeholderTextColor: theme.colors.textDim, style: boxStyle, textStyle: textStyle }) }), hasSecureToggle && (_jsx(Pressable, { style: styles.nativePasswordToggle, onPress: () => setPasswordVisible((v) => !v), accessibilityLabel: passwordVisible ? "Hide password" : "Show password", accessibilityRole: "button", children: _jsx(Icon, { name: passwordVisible ? "eye-off" : "eye", size: spacing.iconSm + 4, color: "textDim" }) }))] }), !!(helperText || errorText) && (_jsx(StyledText, { selectable: false, style: [styles.helperText, hasError && styles.errorText], children: errorText || helperText }))] }));
272
+ }
160
273
  const createStyles = (theme, variant, size) => StyleSheet.create({
274
+ nativeHost: {
275
+ width: "100%",
276
+ },
277
+ nativeRow: {
278
+ flexDirection: "row",
279
+ alignItems: "center",
280
+ gap: spacing.xs,
281
+ },
282
+ nativeHostFlex: {
283
+ flex: 1,
284
+ },
285
+ nativePasswordToggle: {
286
+ paddingHorizontal: spacing.xs,
287
+ },
161
288
  wrapper: {
162
289
  width: "100%",
163
290
  position: "relative",
@@ -1,3 +1,4 @@
1
+ import React from "react";
1
2
  import * as TogglePrimitive from "@rn-primitives/toggle";
2
3
  import { ViewStyle, StyleProp } from "react-native";
3
4
  import type { IconName } from "./Icon";
@@ -83,7 +84,7 @@ interface ToggleProps extends Omit<TogglePrimitive.RootProps, "style"> {
83
84
  * </Toggle>
84
85
  * ```
85
86
  */
86
- declare function Toggle({ variant, size, shape, loading, iconOnly, style: styleOverride, ...props }: ToggleProps): import("react/jsx-runtime").JSX.Element;
87
+ declare function Toggle({ variant, size, shape, loading, iconOnly, style: styleOverride, ...props }: ToggleProps): React.JSX.Element;
87
88
  /**
88
89
  * ToggleIcon Component
89
90
  * Icon wrapper for use inside Toggle buttons
@@ -101,6 +102,6 @@ interface ToggleIconProps {
101
102
  size?: number;
102
103
  color?: string;
103
104
  }
104
- declare function ToggleIcon({ name, size, color }: ToggleIconProps): import("react/jsx-runtime").JSX.Element;
105
+ declare function ToggleIcon({ name, size, color }: ToggleIconProps): React.JSX.Element;
105
106
  export { Toggle, ToggleIcon };
106
107
  export type { ToggleProps };
@@ -1,5 +1,6 @@
1
1
  import type { IconName } from "./Icon";
2
2
  import * as ToggleGroupPrimitive from "@rn-primitives/toggle-group";
3
+ import * as React from "react";
3
4
  type ToggleGroupVariant = "default" | "outline";
4
5
  type ToggleGroupSize = "sm" | "default" | "lg";
5
6
  type ToggleGroupProps = ToggleGroupPrimitive.RootProps & {
@@ -41,7 +42,7 @@ type ToggleGroupProps = ToggleGroupPrimitive.RootProps & {
41
42
  * </ToggleGroup>
42
43
  * ```
43
44
  */
44
- declare function ToggleGroup({ variant, size, children, ...props }: ToggleGroupProps): import("react/jsx-runtime").JSX.Element;
45
+ declare function ToggleGroup({ variant, size, children, ...props }: ToggleGroupProps): React.JSX.Element;
45
46
  type ToggleGroupItemProps = ToggleGroupPrimitive.ItemProps & {
46
47
  /**
47
48
  * Automatically set by ToggleGroup parent - don't set manually
@@ -57,7 +58,7 @@ type ToggleGroupItemProps = ToggleGroupPrimitive.ItemProps & {
57
58
  * Individual toggle button within a ToggleGroup
58
59
  * Position (first/last) is automatically detected for rounded corners
59
60
  */
60
- declare function ToggleGroupItem({ isFirst, isLast, children, ...props }: ToggleGroupItemProps): import("react/jsx-runtime").JSX.Element;
61
+ declare function ToggleGroupItem({ isFirst, isLast, children, ...props }: ToggleGroupItemProps): React.JSX.Element;
61
62
  /**
62
63
  * ToggleGroupIcon Component
63
64
  * Icon wrapper for use inside ToggleGroup items
@@ -75,6 +76,6 @@ interface ToggleGroupIconProps {
75
76
  size?: number;
76
77
  color?: string;
77
78
  }
78
- declare function ToggleGroupIcon({ name, size, color }: ToggleGroupIconProps): import("react/jsx-runtime").JSX.Element;
79
+ declare function ToggleGroupIcon({ name, size, color }: ToggleGroupIconProps): React.JSX.Element;
79
80
  export { ToggleGroup, ToggleGroupIcon, ToggleGroupItem };
80
81
  export type { ToggleGroupProps, ToggleGroupSize, ToggleGroupVariant };
@@ -41,7 +41,7 @@ interface TooltipContentProps extends TooltipPrimitive.ContentProps {
41
41
  * - Smooth animations
42
42
  * - Portal-based rendering for proper z-index
43
43
  */
44
- declare function TooltipContent({ side, align, sideOffset, portalHost, variant, ...props }: TooltipContentProps): import("react/jsx-runtime").JSX.Element;
44
+ declare function TooltipContent({ side, align, sideOffset, portalHost, variant, ...props }: TooltipContentProps): React.JSX.Element;
45
45
  /**
46
46
  * Tooltip Body Component
47
47
  * Simple wrapper for tooltip content with padding
@@ -49,7 +49,7 @@ declare function TooltipContent({ side, align, sideOffset, portalHost, variant,
49
49
  interface TooltipBodyProps extends ViewProps {
50
50
  children: React.ReactNode;
51
51
  }
52
- declare function TooltipBody({ children, style, ...props }: TooltipBodyProps): import("react/jsx-runtime").JSX.Element;
52
+ declare function TooltipBody({ children, style, ...props }: TooltipBodyProps): React.JSX.Element;
53
53
  interface TooltipProps extends TooltipPrimitive.RootProps {
54
54
  /**
55
55
  * Time to wait before showing tooltip (web only)
@@ -99,7 +99,7 @@ interface TooltipProps extends TooltipPrimitive.RootProps {
99
99
  * </Tooltip>
100
100
  * ```
101
101
  */
102
- declare function Tooltip({ delayDuration, skipDelayDuration, ...props }: TooltipProps): import("react/jsx-runtime").JSX.Element;
102
+ declare function Tooltip({ delayDuration, skipDelayDuration, ...props }: TooltipProps): React.JSX.Element;
103
103
  /**
104
104
  * Tooltip Component with Sub-components
105
105
  * Properly typed interface for dot notation access (e.g., Tooltip.Trigger)
@@ -20,4 +20,4 @@ export interface UIProviderProps {
20
20
  */
21
21
  statusBar?: boolean;
22
22
  }
23
- export declare function UIProvider({ children, notification, portalHost, statusBar, }: UIProviderProps): import("react/jsx-runtime").JSX.Element;
23
+ export declare function UIProvider({ children, notification, portalHost, statusBar, }: UIProviderProps): React.JSX.Element;
@@ -21,6 +21,7 @@ export * from "./Notification";
21
21
  export * from "./Popover";
22
22
  export * from "./Progress";
23
23
  export * from "./RadioGroup";
24
+ export * from "./SegmentedControl";
24
25
  export * from "./Select";
25
26
  export * from "./Separator";
26
27
  export * from "./Skeleton";
@@ -21,6 +21,7 @@ export * from "./Notification.js";
21
21
  export * from "./Popover.js";
22
22
  export * from "./Progress.js";
23
23
  export * from "./RadioGroup.js";
24
+ export * from "./SegmentedControl.js";
24
25
  export * from "./Select.js";
25
26
  export * from "./Separator.js";
26
27
  export * from "./Skeleton.js";
@@ -8,9 +8,16 @@
8
8
  * - show({ type, title, messages, duration, loading, action }): displays a notification
9
9
  * - hide(): hides the current notification
10
10
  *
11
- * Recommended: wrap in hooks or utility functions for cleaner usage across components.
11
+ * Notifications auto-dismiss after `DEFAULT_NOTIFICATION_DURATION` unless a
12
+ * `duration` is given. Pass `duration: 0` to keep one up until dismissed;
13
+ * loading notifications never auto-dismiss.
14
+ *
15
+ * Prefer the `notify` helpers (see ./notify) for triggering notifications from
16
+ * app code; use this store directly for reactive subscription (selectors) and tests.
12
17
  */
13
18
  export type GlobalNotificationType = "error" | "success" | "info" | "warning";
19
+ /** Auto-dismiss delay applied when `show()` is called without a `duration`. */
20
+ export declare const DEFAULT_NOTIFICATION_DURATION = 4000;
14
21
  export type GlobalNotificationPosition = "top" | "bottom";
15
22
  export type GlobalNotificationAction = {
16
23
  label: string;
@@ -21,6 +28,7 @@ export type GlobalNotificationAlert = {
21
28
  type: GlobalNotificationType;
22
29
  title?: string;
23
30
  messages?: string[];
31
+ /** Auto-dismiss delay in ms. Defaults to `DEFAULT_NOTIFICATION_DURATION`; 0 = stays until dismissed. */
24
32
  duration?: number;
25
33
  loading?: boolean;
26
34
  /** Where to display the notification */
@@ -1,8 +1,16 @@
1
1
  import { create } from "zustand";
2
+ /** Auto-dismiss delay applied when `show()` is called without a `duration`. */
3
+ export const DEFAULT_NOTIFICATION_DURATION = 4000;
2
4
  export const globalUIStore = create((set) => ({
3
5
  alert: null,
4
6
  show: (alert) => set({
5
- alert: { ...alert, show: true }
7
+ alert: {
8
+ ...alert,
9
+ // Loading notifications stay up until replaced or hidden (e.g. by
10
+ // notify.promise); everything else falls back to the default timeout.
11
+ duration: alert.duration ?? (alert.loading ? undefined : DEFAULT_NOTIFICATION_DURATION),
12
+ show: true,
13
+ },
6
14
  }),
7
15
  hide: () => set({ alert: null }),
8
16
  }));
@@ -1,4 +1,5 @@
1
1
  export * from "./globalUIStore";
2
+ export * from "./notify";
2
3
  export * from "./themeStore";
3
4
  export * from "./themeColorScope";
4
5
  export * from "./SsrViewportContext";
@@ -1,4 +1,5 @@
1
1
  export * from "./globalUIStore.js";
2
+ export * from "./notify.js";
2
3
  export * from "./themeStore.js";
3
4
  export * from "./themeColorScope.js";
4
5
  export * from "./SsrViewportContext.js";
@@ -0,0 +1,50 @@
1
+ import type { GlobalNotificationAlert } from "./globalUIStore";
2
+ /**
3
+ * notify
4
+ *
5
+ * Imperative notification API backed by `globalUIStore`. This is the
6
+ * recommended way to trigger the `Notification` component from app code.
7
+ *
8
+ * Notifications auto-dismiss after `DEFAULT_NOTIFICATION_DURATION` (4s) by
9
+ * default. Pass `duration: 0` to keep one up until dismissed; `notify.loading`
10
+ * is always persistent.
11
+ *
12
+ * Usage:
13
+ * ```ts
14
+ * notify.success("Saved", { messages: ["Your changes have been saved."] });
15
+ * notify.error("Upload failed");
16
+ * notify.loading("Uploading…");
17
+ * notify.hide();
18
+ *
19
+ * // Full control (same payload as globalUIStore show())
20
+ * notify({ type: "info", title: "Copied", duration: 2000, position: "bottom" });
21
+ *
22
+ * // Loading → success/error around a promise
23
+ * await notify.promise(saveProfile(), {
24
+ * loading: "Saving…",
25
+ * success: "Profile saved",
26
+ * error: "Could not save profile",
27
+ * });
28
+ * ```
29
+ */
30
+ export type NotifyOptions = Omit<GlobalNotificationAlert, "show" | "type" | "title">;
31
+ export type NotifyPromiseMessages<T> = {
32
+ loading: string;
33
+ success: string | ((value: T) => string);
34
+ error: string | ((error: unknown) => string);
35
+ };
36
+ export declare const notify: ((alert: Omit<GlobalNotificationAlert, "show">) => void) & {
37
+ success: (title: string, options?: NotifyOptions) => void;
38
+ error: (title: string, options?: NotifyOptions) => void;
39
+ info: (title: string, options?: NotifyOptions) => void;
40
+ warning: (title: string, options?: NotifyOptions) => void;
41
+ /** Persistent spinner notification; stays visible until replaced or hidden. */
42
+ loading: (title: string, options?: NotifyOptions) => void;
43
+ /**
44
+ * Shows a loading notification while the promise is pending, then a
45
+ * success or error notification. Rethrows on rejection and returns the
46
+ * resolved value so it can wrap existing async flows transparently.
47
+ */
48
+ promise: <T>(promise: Promise<T>, messages: NotifyPromiseMessages<T>) => Promise<T>;
49
+ hide: () => void;
50
+ };
@@ -0,0 +1,31 @@
1
+ import { globalUIStore } from "./globalUIStore.js";
2
+ const show = (alert) => globalUIStore.getState().show(alert);
3
+ const showType = (type) => (title, options) => show({ type, title, ...options });
4
+ export const notify = Object.assign(show, {
5
+ success: showType("success"),
6
+ error: showType("error"),
7
+ info: showType("info"),
8
+ warning: showType("warning"),
9
+ /** Persistent spinner notification; stays visible until replaced or hidden. */
10
+ loading: (title, options) => show({ type: "info", title, loading: true, ...options }),
11
+ /**
12
+ * Shows a loading notification while the promise is pending, then a
13
+ * success or error notification. Rethrows on rejection and returns the
14
+ * resolved value so it can wrap existing async flows transparently.
15
+ */
16
+ promise: async (promise, messages) => {
17
+ notify.loading(messages.loading);
18
+ try {
19
+ const value = await promise;
20
+ const title = typeof messages.success === "function" ? messages.success(value) : messages.success;
21
+ notify.success(title);
22
+ return value;
23
+ }
24
+ catch (error) {
25
+ const title = typeof messages.error === "function" ? messages.error(error) : messages.error;
26
+ notify.error(title);
27
+ throw error;
28
+ }
29
+ },
30
+ hide: () => globalUIStore.getState().hide(),
31
+ });
@@ -6,4 +6,4 @@ export declare function ThemeColorScope({ colors, children, }: {
6
6
  /** Per-scheme partial overrides — same shape as `setColors`. */
7
7
  colors: ColorOverrides;
8
8
  children: ReactNode;
9
- }): import("react/jsx-runtime").JSX.Element;
9
+ }): import("react").JSX.Element;
package/llms-full.md CHANGED
@@ -34,7 +34,7 @@ the root when the app uses package feedback or overlay components.
34
34
  `UIProvider` owns the package `Notification`, `StatusBar`, and default
35
35
  `@rn-primitives` portal host. Mount it before using `Dialog`, `AlertDialog`,
36
36
  `BottomSheet`, `Drawer`, `DropdownMenu`, `Popover`, `SelectContent`,
37
- `Tooltip`, or `globalUIStore` notifications.
37
+ `Tooltip`, or `notify` / `globalUIStore` notifications.
38
38
 
39
39
  On native, `BottomSheet.Content` composes its sheet transform with React Native
40
40
  keyboard event values. Pass `avoidKeyboard={false}` for sheets that should not
@@ -66,7 +66,7 @@ import { Button, StyledText, UIProvider } from "@mrmeg/expo-ui/components";
66
66
  import { Button as ButtonDirect } from "@mrmeg/expo-ui/components/Button";
67
67
  import { colors, spacing, typography } from "@mrmeg/expo-ui/constants";
68
68
  import { useResources, useTheme } from "@mrmeg/expo-ui/hooks";
69
- import { globalUIStore, useThemeStore } from "@mrmeg/expo-ui/state";
69
+ import { globalUIStore, notify, useThemeStore } from "@mrmeg/expo-ui/state";
70
70
  import { configureExpoUiI18n, hapticLight } from "@mrmeg/expo-ui/lib";
71
71
  ```
72
72
 
@@ -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`; optional actions dismiss after press. |
104
+ | `Notification` | `@mrmeg/expo-ui/components` | Global toast surface | Trigger through `notify` (or `globalUIStore` for subscriptions/tests) 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. |
@@ -134,6 +134,37 @@ full page sections. Use `EmptyState` for no-data or recoverable error regions,
134
134
  `Skeleton` for loading content with stable layout, and `Progress` for real
135
135
  progress or indeterminate long-running work.
136
136
 
137
+ ## Notifications
138
+
139
+ `notify` is the primary imperative API for triggering the `Notification` component. Import from `@mrmeg/expo-ui/state` (also re-exported from the package root).
140
+
141
+ Notifications auto-dismiss after 4s (`DEFAULT_NOTIFICATION_DURATION`) unless a `duration` is given; pass `duration: 0` to keep one up until dismissed. `notify.loading` never auto-dismisses.
142
+
143
+ ```ts
144
+ import { notify } from "@mrmeg/expo-ui/state";
145
+
146
+ notify.success("Saved", { messages: ["Your changes were saved."] });
147
+ notify.error("Upload failed");
148
+ notify.warning("Connection slow");
149
+ notify.info("Copied to clipboard");
150
+
151
+ // Loading spinner — persists until replaced or hidden (no auto-dismiss)
152
+ notify.loading("Uploading…");
153
+ notify.hide();
154
+
155
+ // Full control (same payload as globalUIStore show())
156
+ notify({ type: "success", title: "Saved", duration: 3000, position: "bottom" });
157
+
158
+ // Loading → success/error around a promise; rethrows on rejection
159
+ await notify.promise(saveProfile(), {
160
+ loading: "Saving…",
161
+ success: "Profile saved", // or (value) => `Saved ${value.name}`
162
+ error: "Could not save profile", // or (err) => err.message
163
+ });
164
+ ```
165
+
166
+ `globalUIStore` (the underlying zustand store) remains available for reactive selectors and tests. Use `notify` for all imperative triggers in app code.
167
+
137
168
  ## Validation
138
169
 
139
170
  Run the UI package gates when changing package code or shipped docs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -108,6 +108,7 @@
108
108
  "@rn-primitives/tooltip": "~1.4.0"
109
109
  },
110
110
  "peerDependencies": {
111
+ "@expo/ui": ">=56.0.0 <57.0.0",
111
112
  "@react-native-async-storage/async-storage": ">=2.2.0 <2.3.0",
112
113
  "expo": ">=55.0.0 <57.0.0",
113
114
  "expo-font": ">=55.0.0 <57.0.0",
@@ -121,7 +122,7 @@
121
122
  "zustand": ">=5.0.0 <6.0.0"
122
123
  },
123
124
  "devDependencies": {
124
- "@types/react": "~19.2.14",
125
+ "@types/react": "~19.2.17",
125
126
  "typescript": "~6.0.3"
126
127
  }
127
128
  }
@@ -1,7 +0,0 @@
1
- import { Animated } from "react-native";
2
- export declare function useBottomSheetKeyboardAnimation(): {
3
- height: Animated.Value;
4
- };
5
- export declare const BottomSheetKeyboardController: {
6
- dismiss(): void;
7
- };
@@ -1,39 +0,0 @@
1
- import { useEffect, useRef } from "react";
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.
7
- function animateKeyboardOffset(value, toValue, duration = 180) {
8
- Animated.timing(value, {
9
- toValue,
10
- duration,
11
- useNativeDriver: false,
12
- }).start();
13
- }
14
- export function useBottomSheetKeyboardAnimation() {
15
- const keyboardHeight = useRef(new Animated.Value(0)).current;
16
- useEffect(() => {
17
- if (Platform.OS === "web") {
18
- return;
19
- }
20
- const showEvent = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
21
- const hideEvent = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
22
- const showSubscription = Keyboard.addListener(showEvent, (event) => {
23
- animateKeyboardOffset(keyboardHeight, event.endCoordinates.height, event.duration || 180);
24
- });
25
- const hideSubscription = Keyboard.addListener(hideEvent, (event) => {
26
- animateKeyboardOffset(keyboardHeight, 0, event.duration || 160);
27
- });
28
- return () => {
29
- showSubscription.remove();
30
- hideSubscription.remove();
31
- };
32
- }, [keyboardHeight]);
33
- return { height: keyboardHeight };
34
- }
35
- export const BottomSheetKeyboardController = {
36
- dismiss() {
37
- Keyboard.dismiss();
38
- },
39
- };