@mrmeg/expo-ui 0.27.0 → 0.28.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 (145) hide show
  1. package/CHANGELOG.md +712 -0
  2. package/LLM_USAGE.md +260 -32
  3. package/README.md +558 -101
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +66 -9
  11. package/dist/components/BottomSheet.js +143 -39
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +64 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +178 -29
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +7 -3
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +74 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +23 -32
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +73 -27
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/bottomSheetHostSupport.d.ts +46 -0
  71. package/dist/components/bottomSheetHostSupport.js +67 -0
  72. package/dist/components/index.d.ts +46 -44
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/keyboardDismiss.d.ts +25 -0
  75. package/dist/components/keyboardDismiss.js +60 -3
  76. package/dist/components/nativeTextField.android.js +13 -9
  77. package/dist/constants/colors.d.ts +40 -5
  78. package/dist/constants/colors.js +26 -8
  79. package/dist/constants/fonts.d.ts +41 -2
  80. package/dist/constants/fonts.js +46 -7
  81. package/dist/constants/index.d.ts +5 -4
  82. package/dist/constants/index.js +1 -0
  83. package/dist/constants/interaction.d.ts +18 -0
  84. package/dist/constants/interaction.js +18 -0
  85. package/dist/constants/spacing.d.ts +2 -1
  86. package/dist/constants/spacing.js +2 -1
  87. package/dist/design-system.json +154 -2
  88. package/dist/hooks/index.d.ts +12 -7
  89. package/dist/hooks/index.js +5 -0
  90. package/dist/hooks/useDimensions.d.ts +13 -8
  91. package/dist/hooks/useDimensions.js +123 -48
  92. package/dist/hooks/useFocusVisible.d.ts +30 -0
  93. package/dist/hooks/useFocusVisible.js +45 -0
  94. package/dist/hooks/useFontStyle.d.ts +2 -2
  95. package/dist/hooks/useFontStyle.js +3 -2
  96. package/dist/hooks/useHydrated.d.ts +13 -0
  97. package/dist/hooks/useHydrated.js +19 -0
  98. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  99. package/dist/hooks/useNotificationOffset.js +34 -0
  100. package/dist/hooks/useResources.d.ts +21 -20
  101. package/dist/hooks/useResources.js +72 -68
  102. package/dist/hooks/useScalePress.d.ts +8 -5
  103. package/dist/hooks/useScalePress.js +16 -9
  104. package/dist/hooks/useShape.d.ts +24 -0
  105. package/dist/hooks/useShape.js +24 -0
  106. package/dist/hooks/useStaggeredEntrance.js +9 -7
  107. package/dist/hooks/useTheme.d.ts +3 -3
  108. package/dist/hooks/useTheme.js +177 -66
  109. package/dist/hooks/useWindowInsets.d.ts +13 -0
  110. package/dist/hooks/useWindowInsets.js +23 -0
  111. package/dist/index.d.ts +5 -5
  112. package/dist/lib/composeRefs.d.ts +8 -0
  113. package/dist/lib/composeRefs.js +20 -0
  114. package/dist/lib/haptics.d.ts +12 -0
  115. package/dist/lib/haptics.js +21 -0
  116. package/dist/lib/index.d.ts +6 -4
  117. package/dist/lib/index.js +2 -0
  118. package/dist/lib/interFonts.d.ts +15 -0
  119. package/dist/lib/interFonts.js +1 -0
  120. package/dist/lib/interFonts.native.d.ts +3 -0
  121. package/dist/lib/interFonts.native.js +20 -0
  122. package/dist/lib/popoverPlacement.d.ts +36 -0
  123. package/dist/lib/popoverPlacement.js +29 -0
  124. package/dist/lib/stateSurface.d.ts +28 -0
  125. package/dist/lib/stateSurface.js +30 -0
  126. package/dist/lib/themedStyles.d.ts +1 -1
  127. package/dist/lib/useAnimatedValue.d.ts +15 -0
  128. package/dist/lib/useAnimatedValue.js +19 -0
  129. package/dist/lib/webHitTarget.d.ts +40 -0
  130. package/dist/lib/webHitTarget.js +37 -0
  131. package/dist/state/feedbackStore.d.ts +24 -0
  132. package/dist/state/feedbackStore.js +16 -0
  133. package/dist/state/globalUIStore.d.ts +27 -0
  134. package/dist/state/globalUIStore.js +23 -0
  135. package/dist/state/index.d.ts +7 -5
  136. package/dist/state/index.js +2 -0
  137. package/dist/state/initialScheme.d.ts +27 -0
  138. package/dist/state/initialScheme.js +29 -0
  139. package/dist/state/notify.d.ts +1 -1
  140. package/dist/state/themeColorScope.d.ts +1 -1
  141. package/dist/state/themeStore.d.ts +69 -16
  142. package/dist/state/themeStore.js +65 -38
  143. package/llms-full.md +56 -8
  144. package/llms.txt +10 -1
  145. package/package.json +69 -2
@@ -1,19 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, use, useEffect, useEffectEvent, useRef } from "react";
2
+ import { useCallback, useEffect, useEffectEvent, useMemo, useRef } from "react";
3
3
  import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
4
- import { SafeAreaInsetsContext } from "react-native-safe-area-context";
4
+ import { useStore } from "zustand";
5
5
  import { Icon } from "./Icon.js";
6
6
  import { useTheme, withAlpha } from "../hooks/useTheme.js";
7
7
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
8
+ import { useWindowInsets } from "../hooks/useWindowInsets.js";
8
9
  import { spacing } from "../constants/spacing.js";
9
10
  import { durations } from "../constants/motion.js";
10
11
  import { StyledText } from "./StyledText.js";
11
12
  import { palette } from "../constants/colors.js";
12
13
  import { translateText } from "../lib/i18n.js";
13
14
  import { createThemedStyles } from "../lib/themedStyles.js";
14
- import { globalUIStore } from "../state/globalUIStore.js";
15
- const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: true };
16
- const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: true };
15
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
16
+ import { shouldUseNativeDriver } from "../lib/animations.js";
17
+ import { globalUIStore, selectNotificationOffset } from "../state/globalUIStore.js";
18
+ const timingIn = { duration: durations.fast, easing: Easing.out(Easing.quad), useNativeDriver: shouldUseNativeDriver };
19
+ const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad), useNativeDriver: shouldUseNativeDriver };
20
+ /**
21
+ * Run an action's handler, then dismiss, even when the handler throws. A
22
+ * module-level function because the React Compiler can't compile a
23
+ * `try`/`finally` and would otherwise skip the whole component.
24
+ */
25
+ function runThenDismiss(run, dismiss) {
26
+ try {
27
+ run();
28
+ }
29
+ finally {
30
+ dismiss();
31
+ }
32
+ }
17
33
  /**
18
34
  * Notification
19
35
  *
@@ -35,22 +51,27 @@ const timingOut = { duration: durations.instant, easing: Easing.in(Easing.quad),
35
51
  export const Notification = () => {
36
52
  const { theme, getShadowStyle } = useTheme();
37
53
  const reduceMotion = useReducedMotion();
38
- const insets = use(SafeAreaInsetsContext);
39
- const { alert, hide } = globalUIStore();
54
+ // Window insets, not the provider context alone: a toast host above the
55
+ // app's SafeAreaProvider (or inside a Modal) used to fall back to 20 pt and
56
+ // sit under the Dynamic Island.
57
+ const insets = useWindowInsets();
58
+ // Read the store through zustand's `useStore`, not by calling
59
+ // `globalUIStore()` directly: the React Compiler recognizes hooks by their
60
+ // `use` prefix, so it treated that call as a plain function, cached its
61
+ // result, and skipped the hook on the next render (React error #311).
62
+ const { alert, hide } = useStore(globalUIStore);
63
+ // Bars a layout registered with `useNotificationOffset` (a tab bar, a
64
+ // floating header), measured from the window edge like the insets. Select
65
+ // the stable record and derive the maxima here: a selector that built a new
66
+ // object per call would never satisfy `useSyncExternalStore`'s snapshot check.
67
+ const notificationOffsets = useStore(globalUIStore, (state) => state.notificationOffsets);
68
+ const offset = useMemo(() => selectNotificationOffset({ notificationOffsets }), [notificationOffsets]);
40
69
  const styles = themedStyles(theme);
41
70
  const position = alert?.position ?? "top";
42
71
  const isBottom = position === "bottom";
43
72
  // Just opacity + translateY — no scale (scale = bouncy feel)
44
- const opacityRef = useRef(null);
45
- if (opacityRef.current === null) {
46
- opacityRef.current = new Animated.Value(0);
47
- }
48
- const opacity = opacityRef.current;
49
- const translateYRef = useRef(null);
50
- if (translateYRef.current === null) {
51
- translateYRef.current = new Animated.Value(0);
52
- }
53
- const translateY = translateYRef.current;
73
+ const opacity = useAnimatedValue(0);
74
+ const translateY = useAnimatedValue(0);
54
75
  const wasVisibleRef = useRef(false);
55
76
  const timerRef = useRef(null);
56
77
  const hideNotification = useCallback(() => {
@@ -88,12 +109,7 @@ export const Notification = () => {
88
109
  const action = alert?.action;
89
110
  if (!action)
90
111
  return;
91
- try {
92
- action.onPress();
93
- }
94
- finally {
95
- animateOut();
96
- }
112
+ runThenDismiss(() => action.onPress(), animateOut);
97
113
  }, [alert?.action, animateOut]);
98
114
  // The auto-dismiss timer only needs the latest animateOut; wrapping it in an
99
115
  // Effect Event keeps it out of the deps so the effect doesn't re-run (and
@@ -150,8 +166,10 @@ export const Notification = () => {
150
166
  opacity,
151
167
  transform: [{ translateY }],
152
168
  };
153
- const topPosition = insets?.top ? insets.top : 20;
154
- const bottomPosition = insets?.bottom ? insets.bottom : 20;
169
+ // 8 pt of air past the inset or a registered bar, whichever reaches further;
170
+ // without either, 20 pt from the edge (as before).
171
+ const topPosition = Math.max(insets.top, spacing.smd, offset.top) + spacing.sm;
172
+ const bottomPosition = Math.max(insets.bottom, spacing.smd, offset.bottom) + spacing.sm;
155
173
  const getIconProps = () => {
156
174
  switch (alert?.type) {
157
175
  case "error":
@@ -208,7 +226,7 @@ export const Notification = () => {
208
226
  if (!alert?.show) {
209
227
  return null;
210
228
  }
211
- return (_jsx(Animated.View, { accessibilityLiveRegion: "polite", accessibilityRole: "alert", style: [
229
+ return (_jsx(Animated.View, { testID: "ui-notification", accessibilityLiveRegion: "polite", accessibilityRole: "alert", style: [
212
230
  styles.container,
213
231
  isBottom
214
232
  ? { bottom: bottomPosition }
@@ -302,4 +320,4 @@ const createStyles = (theme) => StyleSheet.create({
302
320
  ...(Platform.OS === "web" && { cursor: "pointer" }),
303
321
  },
304
322
  });
305
- const themedStyles = createThemedStyles(createStyles);
323
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);
@@ -3,22 +3,34 @@ import * as React from "react";
3
3
  import { ViewProps } from "react-native";
4
4
  /**
5
5
  * Popover Trigger Component
6
- * The element that triggers the popover to open/close
6
+ * The element that triggers the popover to open/close. Its ref exposes
7
+ * `open()` / `close()` for driving the popover from outside.
7
8
  */
8
- type PopoverTriggerProps = PopoverPrimitive.TriggerProps;
9
+ type PopoverTriggerProps = PopoverPrimitive.TriggerProps & React.RefAttributes<PopoverPrimitive.TriggerRef>;
10
+ /** What a `PopoverTrigger` ref holds: the pressable plus `open()` / `close()`. */
11
+ type PopoverTriggerRef = PopoverPrimitive.TriggerRef;
9
12
  declare function PopoverTrigger({ disabled, ...props }: PopoverTriggerProps): React.JSX.Element;
10
13
  interface PopoverContentProps extends PopoverPrimitive.ContentProps {
11
14
  /**
12
15
  * Optional portal host name for custom portal mounting
13
16
  */
14
17
  portalHost?: string;
18
+ /**
19
+ * Scroll the children once they outgrow the room on the popover's side
20
+ * (default). Pass `false` for content that brings its own list: a `FlatList`
21
+ * inside a vertical ScrollView warns. The height cap still applies, but the
22
+ * popover can no longer measure its full height, so it stays on `side`.
23
+ */
24
+ scrollable?: boolean;
15
25
  }
16
26
  /**
17
27
  * Popover Content Component
18
- * The content that appears in the popover overlay
19
- * Supports smart positioning, animations, and theme integration
28
+ * The content that appears in the popover overlay. `side` is a preference: on
29
+ * native the popover opens on the other side when the content does not fit
30
+ * and there is more room there, caps its height to the room, and scrolls.
31
+ * A `style` merges over the themed surface; `insets` default to the safe area.
20
32
  */
21
- declare function PopoverContent({ side, align, sideOffset, portalHost, ...props }: PopoverContentProps): React.JSX.Element;
33
+ declare function PopoverContent({ portalHost, insets, ...props }: PopoverContentProps): React.JSX.Element;
22
34
  /**
23
35
  * Popover Header Component
24
36
  * Optional header section for the popover content
@@ -62,3 +74,4 @@ type PopoverComponent = typeof PopoverRoot & {
62
74
  */
63
75
  declare const Popover: PopoverComponent;
64
76
  export { Popover, PopoverTrigger, PopoverContent, PopoverHeader, PopoverBody, PopoverFooter, };
77
+ export type { PopoverTriggerRef };
@@ -1,14 +1,22 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AnimatedView } from "./AnimatedView.js";
3
- import { TextClassContext, TextColorContext } from "./StyledText.context";
3
+ import { TextClassContext, TextColorContext } from "./StyledText.context.js";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { useScalePress } from "../hooks/useScalePress.js";
7
+ import { resolvePopoverPlacement } from "../lib/popoverPlacement.js";
7
8
  import * as PopoverPrimitive from "@rn-primitives/popover";
8
9
  import * as React from "react";
9
- import { Animated, Platform, StyleSheet, View } from "react-native";
10
+ import { Animated, Dimensions, Platform, ScrollView, StyleSheet, View, } from "react-native";
11
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
10
12
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
11
13
  import { palette } from "../constants/colors.js";
14
+ // The overlay layer covers the screen but must not swallow touches around the
15
+ // content; a style, not the deprecated `pointerEvents` prop (web warns and
16
+ // lazily loads its warning module mid-render).
17
+ const styles = /*#__PURE__*/ StyleSheet.create({
18
+ passThrough: { pointerEvents: "box-none" },
19
+ });
12
20
  function PopoverTrigger({ disabled, ...props }) {
13
21
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
14
22
  disabled: !!disabled,
@@ -22,15 +30,69 @@ function PopoverTrigger({ disabled, ...props }) {
22
30
  * On Android/Web, uses Fragment to avoid unnecessary nesting
23
31
  */
24
32
  const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
33
+ /**
34
+ * Radix measures the viewport room on web and exposes it on the content
35
+ * element; the primitive's web Content applies no size cap of its own.
36
+ */
37
+ const WEB_PLACEMENT_STYLE = {
38
+ maxWidth: "var(--radix-popover-content-available-width)",
39
+ maxHeight: "var(--radix-popover-content-available-height)",
40
+ };
41
+ const claimNoTouch = () => false;
25
42
  /**
26
43
  * Popover Content Component
27
- * The content that appears in the popover overlay
28
- * Supports smart positioning, animations, and theme integration
44
+ * The content that appears in the popover overlay. `side` is a preference: on
45
+ * native the popover opens on the other side when the content does not fit
46
+ * and there is more room there, caps its height to the room, and scrolls.
47
+ * A `style` merges over the themed surface; `insets` default to the safe area.
29
48
  */
30
- function PopoverContent({ side, align = "center", sideOffset = 4, portalHost, ...props }) {
31
- const { theme, getShadowStyle, getContrastingColor } = useTheme();
49
+ function PopoverContent({ portalHost, insets, ...props }) {
50
+ const { theme, getContrastingColor } = useTheme();
51
+ const safeAreaInsets = useSafeAreaInsets();
32
52
  // Calculate text color for popover content based on background
33
53
  const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
54
+ const card = (_jsx(AnimatedView, { type: "fade", enterDuration: 200, style: [StyleSheet.absoluteFill, styles.passThrough], collapsable: false, children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(PopoverCard, { insets: insets ?? safeAreaInsets, ...props }) }) }) }));
55
+ return (_jsx(PopoverPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: Platform.OS === "web" ? (_jsx(PopoverPrimitive.Overlay, { children: card })) : (_jsxs(_Fragment, { children: [_jsx(PopoverPrimitive.Overlay, { style: StyleSheet.absoluteFill }), card] })) }) }));
56
+ }
57
+ /**
58
+ * The positioned card. Rendered inside the portal, so it mounts on open and
59
+ * its measurements start fresh every time.
60
+ */
61
+ function PopoverCard({ side, align = "center", sideOffset = 4, insets, avoidCollisions = true, scrollable = true, style, children, testID, ...props }) {
62
+ const { theme, getShadowStyle } = useTheme();
63
+ const { triggerPosition, contentLayout } = PopoverPrimitive.useRootContext();
64
+ const [scrollFrameHeight, setScrollFrameHeight] = React.useState();
65
+ const [scrollContentHeight, setScrollContentHeight] = React.useState();
66
+ const isWeb = Platform.OS === "web";
67
+ const wrapsInScroll = scrollable && !props.asChild;
68
+ // The card's own chrome (padding, border) plus the uncapped scroll content.
69
+ const naturalHeight = wrapsInScroll &&
70
+ contentLayout &&
71
+ scrollFrameHeight !== undefined &&
72
+ scrollContentHeight !== undefined
73
+ ? contentLayout.height - scrollFrameHeight + scrollContentHeight
74
+ : undefined;
75
+ let placementSide = side;
76
+ let placementStyle = WEB_PLACEMENT_STYLE;
77
+ if (!isWeb) {
78
+ const placement = resolvePopoverPlacement({
79
+ side,
80
+ sideOffset,
81
+ trigger: triggerPosition ?? null,
82
+ screenHeight: Dimensions.get("screen").height,
83
+ insets: insets ?? {},
84
+ naturalHeight,
85
+ avoidCollisions,
86
+ });
87
+ placementSide = placement.side;
88
+ // Hide the first measured frame while it may still flip. The key is
89
+ // omitted otherwise, so the primitive's hidden-until-measured style holds.
90
+ const mayStillFlip = wrapsInScroll && avoidCollisions && !!triggerPosition && naturalHeight === undefined;
91
+ placementStyle = {
92
+ ...(placement.maxHeight !== undefined ? { maxHeight: placement.maxHeight } : null),
93
+ ...(mayStillFlip ? { opacity: 0 } : null),
94
+ };
95
+ }
34
96
  const contentStyle = StyleSheet.flatten([
35
97
  {
36
98
  backgroundColor: theme.colors.popover,
@@ -40,8 +102,10 @@ function PopoverContent({ side, align = "center", sideOffset = 4, portalHost, ..
40
102
  padding: spacing.xs,
41
103
  ...getShadowStyle("soft"),
42
104
  },
105
+ placementStyle,
106
+ style,
43
107
  ]) ?? undefined;
44
- return (_jsx(PopoverPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(PopoverPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsx(AnimatedView, { type: "fade", enterDuration: 200, style: StyleSheet.absoluteFill, pointerEvents: "box-none", children: _jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(PopoverPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, style: contentStyle, ...props }) }) }) }) }) }) }));
108
+ return (_jsx(PopoverPrimitive.Content, { side: placementSide, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: avoidCollisions, testID: testID, ...(isWeb ? null : { onStartShouldSetResponder: claimNoTouch }), ...props, style: contentStyle, children: wrapsInScroll ? (_jsx(ScrollView, { testID: testID ? `${testID}-scroll` : undefined, alwaysBounceVertical: false, keyboardShouldPersistTaps: "handled", onLayout: (event) => setScrollFrameHeight(event.nativeEvent.layout.height), onContentSizeChange: (_width, height) => setScrollContentHeight(height), children: children })) : (children) }));
45
109
  }
46
110
  function PopoverHeader({ children, style, ...props }) {
47
111
  const { theme } = useTheme();
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useEffect, useRef } from "react";
2
+ import { useEffect } from "react";
3
3
  import { Animated, StyleSheet, View } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
6
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
7
+ import { shouldUseNativeDriver } from "../lib/animations.js";
6
8
  // ============================================================================
7
9
  // Constants
8
10
  // ============================================================================
@@ -55,12 +57,12 @@ function DeterminateFill({ value, fillColor, height, borderRadius, reduceMotion,
55
57
  // Animate scaleX (GPU compositor) instead of width (JS-thread layout each
56
58
  // frame). transformOrigin "left" grows the fill from the left edge, so a
57
59
  // full-width bar scaled by clamped/100 needs no container measurement.
58
- const scaleX = useRef(new Animated.Value(0)).current;
60
+ const scaleX = useAnimatedValue(0);
59
61
  useEffect(() => {
60
62
  Animated.timing(scaleX, {
61
63
  toValue: clamped / 100,
62
64
  duration: reduceMotion ? 0 : 300,
63
- useNativeDriver: true,
65
+ useNativeDriver: shouldUseNativeDriver,
64
66
  }).start();
65
67
  }, [clamped, reduceMotion, scaleX]);
66
68
  return (_jsx(Animated.View, { style: [
@@ -75,7 +77,7 @@ function DeterminateFill({ value, fillColor, height, borderRadius, reduceMotion,
75
77
  ] }));
76
78
  }
77
79
  function IndeterminateFill({ fillColor, height, borderRadius, reduceMotion, }) {
78
- const opacity = useRef(new Animated.Value(reduceMotion ? 0.7 : 0.4)).current;
80
+ const opacity = useAnimatedValue(reduceMotion ? 0.7 : 0.4);
79
81
  useEffect(() => {
80
82
  if (reduceMotion) {
81
83
  opacity.setValue(0.7);
@@ -86,12 +88,12 @@ function IndeterminateFill({ fillColor, height, borderRadius, reduceMotion, }) {
86
88
  Animated.timing(opacity, {
87
89
  toValue: 1,
88
90
  duration: 800,
89
- useNativeDriver: true,
91
+ useNativeDriver: shouldUseNativeDriver,
90
92
  }),
91
93
  Animated.timing(opacity, {
92
94
  toValue: 0.4,
93
95
  duration: 800,
94
- useNativeDriver: true,
96
+ useNativeDriver: shouldUseNativeDriver,
95
97
  }),
96
98
  ]));
97
99
  animation.start();
@@ -1,12 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useEffect, useRef, useState } from "react";
2
+ import React, { createContext, use, useEffect } from "react";
3
3
  import { View, StyleSheet, Pressable, Platform, Animated } from "react-native";
4
4
  import { StyledText } from "./StyledText.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
6
7
  import { spacing } from "../constants/spacing.js";
8
+ import { interaction } from "../constants/interaction.js";
7
9
  import { hapticLight } from "../lib/haptics.js";
10
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
8
11
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
9
12
  import { useScalePress } from "../hooks/useScalePress.js";
13
+ import { shouldUseNativeDriver } from "../lib/animations.js";
10
14
  import * as RadioGroupPrimitive from "@rn-primitives/radio-group";
11
15
  const DEFAULT_HIT_SLOP = 8;
12
16
  const SIZE_CONFIGS = {
@@ -60,38 +64,20 @@ function RadioGroupItem({ label, required = false, style: styleOverride, labelSt
60
64
  const { size, error, value: groupValue, onValueChange } = useRadioGroupContext();
61
65
  const sizeConfig = SIZE_CONFIGS[size];
62
66
  const focusRingStyle = getFocusRingStyle();
63
- const [focused, setFocused] = useState(false);
64
67
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
65
68
  disabled: !!disabled,
66
69
  scaleTo: 0.92,
67
70
  haptic: false,
68
71
  });
69
- const showFocusRing = (event) => {
70
- let ringVisible = true;
71
- if (Platform.OS === "web") {
72
- const target = event?.nativeEvent?.target;
73
- if (target && typeof target.matches === "function") {
74
- try {
75
- ringVisible = target.matches(":focus-visible");
76
- }
77
- catch {
78
- ringVisible = true;
79
- }
80
- }
81
- }
82
- setFocused(ringVisible);
83
- };
84
- const hideFocusRing = () => {
85
- setFocused(false);
86
- };
72
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible();
87
73
  const isChecked = groupValue === itemValue;
88
74
  // Animated dot scale — follows Checkbox opacity pattern
89
- const dotScale = useRef(new Animated.Value(isChecked ? 1 : 0)).current;
75
+ const dotScale = useAnimatedValue(isChecked ? 1 : 0);
90
76
  useEffect(() => {
91
77
  Animated.timing(dotScale, {
92
78
  toValue: isChecked ? 1 : 0,
93
79
  duration: reduceMotion ? 0 : 60,
94
- useNativeDriver: true,
80
+ useNativeDriver: shouldUseNativeDriver,
95
81
  }).start();
96
82
  }, [isChecked, reduceMotion, dotScale]);
97
83
  // Border color follows Checkbox pattern
@@ -101,48 +87,65 @@ function RadioGroupItem({ label, required = false, style: styleOverride, labelSt
101
87
  ? theme.colors.primary
102
88
  : getContrastingColor(theme.colors.background, theme.colors.text, theme.colors.textDim);
103
89
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
104
- const radioElement = (_jsx(Animated.View, { style: scaleStyle, children: _jsx(RadioGroupPrimitive.Item, { ...props, value: itemValue, disabled: disabled, onPress: handleRadioPress, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
105
- ...styles.radio,
106
- borderColor,
107
- backgroundColor: theme.colors.background,
108
- borderRadius: sizeConfig.outer / 2,
109
- borderWidth: sizeConfig.borderWidth,
110
- width: sizeConfig.outer,
111
- height: sizeConfig.outer,
112
- opacity: disabled ? 0.5 : 1,
90
+ // The primitive Item is the ONE control; the drawn circle is a child so the
91
+ // control can be bigger than it looks. `hitSlop` is inert on web, where a
92
+ // 16/20px circle sits under the 24px WCAG 2.5.8 minimum: the control grows
93
+ // to `spacing.minTarget` and takes the extra back in negative margins.
94
+ const hitSize = Platform.OS === "web" ? Math.max(sizeConfig.outer, spacing.minTarget) : sizeConfig.outer;
95
+ const hitOverflow = (hitSize - sizeConfig.outer) / 2;
96
+ // The label text names the control on every platform; no `useId` link (ids
97
+ // do not survive Expo Router's streamed hydration — see hooks/useHydrated).
98
+ const labelLink = label !== undefined ? { accessibilityLabel: label } : undefined;
99
+ const radioElement = (_jsx(Animated.View, { style: scaleStyle, children: _jsx(RadioGroupPrimitive.Item, { ...labelLink, ...props, value: itemValue, disabled: disabled, onPress: handleRadioPress, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
100
+ ...styles.control,
101
+ minWidth: hitSize,
102
+ minHeight: hitSize,
103
+ ...(hitOverflow > 0 && { margin: -hitOverflow }),
113
104
  ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
114
- ...(focused && !disabled ? focusRingStyle : null),
115
- ...(flattenedStyle || {}),
116
- }, hitSlop: DEFAULT_HIT_SLOP, accessibilityLabel: label, children: _jsx(Animated.View, { style: [
117
- { transform: [{ scale: dotScale }] },
118
- {
119
- width: sizeConfig.inner,
120
- height: sizeConfig.inner,
121
- borderRadius: sizeConfig.inner / 2,
122
- backgroundColor: theme.colors.primary,
123
- },
124
- ] }) }) }));
105
+ }, hitSlop: DEFAULT_HIT_SLOP, children: _jsx(View, { style: {
106
+ ...styles.radio,
107
+ borderColor,
108
+ backgroundColor: theme.colors.background,
109
+ borderRadius: sizeConfig.outer / 2,
110
+ borderWidth: sizeConfig.borderWidth,
111
+ width: sizeConfig.outer,
112
+ height: sizeConfig.outer,
113
+ opacity: disabled ? 0.5 : 1,
114
+ ...(focused && !disabled ? focusRingStyle : null),
115
+ ...(flattenedStyle || {}),
116
+ }, children: _jsx(Animated.View, { style: [
117
+ { transform: [{ scale: dotScale }] },
118
+ {
119
+ width: sizeConfig.inner,
120
+ height: sizeConfig.inner,
121
+ borderRadius: sizeConfig.inner / 2,
122
+ backgroundColor: theme.colors.primary,
123
+ },
124
+ ] }) }) }) }));
125
125
  // If no label, return just the radio button
126
126
  if (!label) {
127
127
  return radioElement;
128
128
  }
129
- // With label, wrap in a pressable row — tapping the label selects the item
129
+ // With a label, a non-accessible wrapper extends the tap area to the text;
130
+ // the primitive Item above stays the only radio a screen reader meets, and
131
+ // it carries the label text as its name.
130
132
  return (_jsxs(Pressable, { onPress: () => {
131
133
  if (!disabled) {
132
134
  hapticLight();
133
135
  onValueChange(itemValue);
134
136
  }
135
- }, style: [styles.container, labelStyle], disabled: disabled, accessibilityRole: "radio", accessibilityState: {
136
- checked: isChecked,
137
- disabled: !!disabled,
138
- }, accessibilityLabel: label, children: [radioElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: [
137
+ }, style: [styles.container, labelStyle], disabled: disabled, accessible: false, focusable: false, children: [radioElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: [
139
138
  styles.label,
140
139
  { color: theme.colors.text },
141
140
  disabled && styles.disabledLabel,
142
141
  error && { color: theme.colors.destructive },
143
142
  ], children: [label, required && (_jsx(StyledText, { selectable: false, style: [styles.required, { color: theme.colors.destructive }], children: " *" }))] }) })] }));
144
143
  }
145
- const styles = StyleSheet.create({
144
+ const styles = /*#__PURE__*/ StyleSheet.create({
145
+ control: {
146
+ justifyContent: "center",
147
+ alignItems: "center",
148
+ },
146
149
  radio: {
147
150
  justifyContent: "center",
148
151
  alignItems: "center",
@@ -160,7 +163,7 @@ const styles = StyleSheet.create({
160
163
  fontSize: 14,
161
164
  },
162
165
  disabledLabel: {
163
- opacity: 0.5,
166
+ opacity: interaction.disabledOpacity,
164
167
  },
165
168
  required: {
166
169
  fontWeight: "bold",
@@ -0,0 +1,61 @@
1
+ import React from "react";
2
+ import { type ScrollViewProps, type StyleProp, type ViewStyle } from "react-native";
3
+ export type ScreenEdge = "top" | "bottom" | "left" | "right";
4
+ export type ScreenEdges = ReadonlyArray<ScreenEdge>;
5
+ export interface ScreenProps {
6
+ /**
7
+ * Which safe-area edges this screen owns. Required, with no default, so a
8
+ * screen never pads an edge a navigator already insets: under a Stack header
9
+ * pass `["bottom"]`; above a tab bar `["top"]`; inside a modal or form sheet
10
+ * `["bottom"]`; a headerless full screen `["top", "bottom"]`; `[]` when the
11
+ * navigator owns both.
12
+ */
13
+ edges: ScreenEdges;
14
+ /**
15
+ * `true` renders a `ScrollView` and puts the inset padding on its content
16
+ * container, so content starts clear of the island / home indicator but
17
+ * scrolls under them. `false` (default) renders a `View` padded on itself.
18
+ */
19
+ scroll?: boolean;
20
+ /** Extra `ScrollView` props when `scroll` is set (`refreshControl`, `keyboardShouldPersistTaps`, …). */
21
+ scrollProps?: Omit<ScrollViewProps, "style" | "contentContainerStyle" | "children">;
22
+ /**
23
+ * The screen's one horizontal inset, `spacing.screenPadding` (16), plus the
24
+ * left/right safe-area insets when those edges are named. `false` for
25
+ * full-bleed content such as `ItemGroup` rows, which carry their own 16.
26
+ * @default true
27
+ */
28
+ padded?: boolean;
29
+ /** Style of the outer View / ScrollView (defaults to `flex: 1` on the theme background). */
30
+ style?: StyleProp<ViewStyle>;
31
+ /**
32
+ * Content padding and layout. Its `padding*` values are added to the safe-area
33
+ * insets (`paddingBottom: 48` on a 34 pt home indicator gives 82), never
34
+ * replaced by them. On the `View` variant it merges into `style`.
35
+ */
36
+ contentContainerStyle?: StyleProp<ViewStyle>;
37
+ children?: React.ReactNode;
38
+ testID?: string;
39
+ }
40
+ /**
41
+ * Screen
42
+ *
43
+ * The page container: safe-area insets on the edges the screen owns, the one
44
+ * 16 pt horizontal inset, and the theme background under all of it. Insets
45
+ * come from `useWindowInsets`, so a screen inside a modal or a native sheet
46
+ * still clears the home indicator.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * // Under a Stack header, above no tab bar
51
+ * <Screen edges={["bottom"]} scroll contentContainerStyle={{ paddingBottom: spacing.xxl }}>
52
+ * …
53
+ * </Screen>
54
+ *
55
+ * // Headerless, full-bleed rows
56
+ * <Screen edges={["top", "bottom"]} padded={false}>
57
+ * <ItemGroup title="Account">…</ItemGroup>
58
+ * </Screen>
59
+ * ```
60
+ */
61
+ export declare function Screen({ edges, scroll, scrollProps, padded, style, contentContainerStyle, children, testID, }: ScreenProps): React.JSX.Element;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ScrollView, StyleSheet, View, } from "react-native";
3
+ import { useTheme } from "../hooks/useTheme.js";
4
+ import { useWindowInsets } from "../hooks/useWindowInsets.js";
5
+ import { spacing } from "../constants/spacing.js";
6
+ /** The caller's padding on one edge, honouring the `padding` / `paddingVertical` / `paddingHorizontal` shorthands. */
7
+ function ownPadding(style, key) {
8
+ if (!style)
9
+ return 0;
10
+ const axis = key === "paddingTop" || key === "paddingBottom" ? style.paddingVertical : style.paddingHorizontal;
11
+ const value = style[key] ?? axis ?? style.padding;
12
+ return typeof value === "number" ? value : 0;
13
+ }
14
+ /**
15
+ * Screen
16
+ *
17
+ * The page container: safe-area insets on the edges the screen owns, the one
18
+ * 16 pt horizontal inset, and the theme background under all of it. Insets
19
+ * come from `useWindowInsets`, so a screen inside a modal or a native sheet
20
+ * still clears the home indicator.
21
+ *
22
+ * @example
23
+ * ```tsx
24
+ * // Under a Stack header, above no tab bar
25
+ * <Screen edges={["bottom"]} scroll contentContainerStyle={{ paddingBottom: spacing.xxl }}>
26
+ * …
27
+ * </Screen>
28
+ *
29
+ * // Headerless, full-bleed rows
30
+ * <Screen edges={["top", "bottom"]} padded={false}>
31
+ * <ItemGroup title="Account">…</ItemGroup>
32
+ * </Screen>
33
+ * ```
34
+ */
35
+ export function Screen({ edges, scroll = false, scrollProps, padded = true, style, contentContainerStyle, children, testID, }) {
36
+ const { theme } = useTheme();
37
+ const insets = useWindowInsets();
38
+ const has = (edge) => edges.includes(edge);
39
+ const own = StyleSheet.flatten(contentContainerStyle);
40
+ const horizontal = padded ? spacing.screenPadding : 0;
41
+ // Every edge the caller pads or the screen owns gets one resolved value:
42
+ // the caller's spacing plus the inset. Edges neither side touches stay
43
+ // unset so a caller style can still use shorthands for them.
44
+ const padding = {};
45
+ const top = ownPadding(own, "paddingTop") + (has("top") ? insets.top : 0);
46
+ const bottom = ownPadding(own, "paddingBottom") + (has("bottom") ? insets.bottom : 0);
47
+ const left = ownPadding(own, "paddingLeft") + horizontal + (has("left") ? insets.left : 0);
48
+ const right = ownPadding(own, "paddingRight") + horizontal + (has("right") ? insets.right : 0);
49
+ if (has("top") || own?.paddingTop !== undefined || own?.paddingVertical !== undefined || own?.padding !== undefined)
50
+ padding.paddingTop = top;
51
+ if (has("bottom") || own?.paddingBottom !== undefined || own?.paddingVertical !== undefined || own?.padding !== undefined)
52
+ padding.paddingBottom = bottom;
53
+ if (left)
54
+ padding.paddingLeft = left;
55
+ if (right)
56
+ padding.paddingRight = right;
57
+ const base = { flex: 1, backgroundColor: theme.colors.background };
58
+ if (scroll) {
59
+ return (_jsx(ScrollView, { style: [base, style], contentContainerStyle: [own, padding], testID: testID, ...scrollProps, children: children }));
60
+ }
61
+ return (_jsx(View, { style: [base, own, padding, style], testID: testID, children: children }));
62
+ }
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { StyleProp, ViewStyle, TextStyle } from "react-native";
3
- import { type TextProps } from "./StyledText";
3
+ import { type TextProps } from "./StyledText.js";
4
4
  type TextPassthroughProps = Omit<TextProps, "style" | "children"> & {
5
5
  style?: StyleProp<TextStyle>;
6
6
  };