@mrmeg/expo-ui 0.27.1 → 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 (140) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/LLM_USAGE.md +233 -35
  3. package/README.md +504 -103
  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 +29 -1
  11. package/dist/components/BottomSheet.js +107 -30
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +60 -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 +87 -16
  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 +5 -2
  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 +73 -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 +20 -30
  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 +71 -26
  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/index.d.ts +46 -44
  71. package/dist/components/index.js +2 -0
  72. package/dist/components/keyboardDismiss.js +12 -4
  73. package/dist/components/nativeTextField.android.js +13 -9
  74. package/dist/constants/colors.d.ts +40 -5
  75. package/dist/constants/colors.js +26 -8
  76. package/dist/constants/fonts.d.ts +41 -2
  77. package/dist/constants/fonts.js +46 -7
  78. package/dist/constants/index.d.ts +5 -4
  79. package/dist/constants/index.js +1 -0
  80. package/dist/constants/interaction.d.ts +18 -0
  81. package/dist/constants/interaction.js +18 -0
  82. package/dist/constants/spacing.d.ts +2 -1
  83. package/dist/constants/spacing.js +2 -1
  84. package/dist/design-system.json +154 -2
  85. package/dist/hooks/index.d.ts +12 -7
  86. package/dist/hooks/index.js +5 -0
  87. package/dist/hooks/useDimensions.d.ts +13 -8
  88. package/dist/hooks/useDimensions.js +123 -48
  89. package/dist/hooks/useFocusVisible.d.ts +30 -0
  90. package/dist/hooks/useFocusVisible.js +45 -0
  91. package/dist/hooks/useFontStyle.d.ts +2 -2
  92. package/dist/hooks/useFontStyle.js +3 -2
  93. package/dist/hooks/useHydrated.d.ts +13 -0
  94. package/dist/hooks/useHydrated.js +19 -0
  95. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  96. package/dist/hooks/useNotificationOffset.js +34 -0
  97. package/dist/hooks/useResources.d.ts +21 -20
  98. package/dist/hooks/useResources.js +72 -68
  99. package/dist/hooks/useScalePress.d.ts +8 -5
  100. package/dist/hooks/useScalePress.js +16 -9
  101. package/dist/hooks/useShape.d.ts +24 -0
  102. package/dist/hooks/useShape.js +24 -0
  103. package/dist/hooks/useStaggeredEntrance.js +9 -7
  104. package/dist/hooks/useTheme.d.ts +3 -3
  105. package/dist/hooks/useTheme.js +177 -66
  106. package/dist/hooks/useWindowInsets.d.ts +13 -0
  107. package/dist/hooks/useWindowInsets.js +23 -0
  108. package/dist/index.d.ts +5 -5
  109. package/dist/lib/composeRefs.d.ts +8 -0
  110. package/dist/lib/composeRefs.js +20 -0
  111. package/dist/lib/haptics.d.ts +12 -0
  112. package/dist/lib/haptics.js +21 -0
  113. package/dist/lib/index.d.ts +6 -5
  114. package/dist/lib/index.js +1 -0
  115. package/dist/lib/interFonts.d.ts +15 -0
  116. package/dist/lib/interFonts.js +1 -0
  117. package/dist/lib/interFonts.native.d.ts +3 -0
  118. package/dist/lib/interFonts.native.js +20 -0
  119. package/dist/lib/popoverPlacement.d.ts +36 -0
  120. package/dist/lib/popoverPlacement.js +29 -0
  121. package/dist/lib/themedStyles.d.ts +1 -1
  122. package/dist/lib/useAnimatedValue.d.ts +15 -0
  123. package/dist/lib/useAnimatedValue.js +19 -0
  124. package/dist/lib/webHitTarget.d.ts +40 -0
  125. package/dist/lib/webHitTarget.js +37 -0
  126. package/dist/state/feedbackStore.d.ts +24 -0
  127. package/dist/state/feedbackStore.js +16 -0
  128. package/dist/state/globalUIStore.d.ts +27 -0
  129. package/dist/state/globalUIStore.js +23 -0
  130. package/dist/state/index.d.ts +7 -5
  131. package/dist/state/index.js +2 -0
  132. package/dist/state/initialScheme.d.ts +27 -0
  133. package/dist/state/initialScheme.js +29 -0
  134. package/dist/state/notify.d.ts +1 -1
  135. package/dist/state/themeColorScope.d.ts +1 -1
  136. package/dist/state/themeStore.d.ts +69 -16
  137. package/dist/state/themeStore.js +65 -38
  138. package/llms-full.md +56 -8
  139. package/llms.txt +10 -1
  140. package/package.json +69 -2
@@ -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
  };
@@ -7,18 +7,26 @@ import { type StyleProp, type ViewStyle } from "react-native";
7
7
  *
8
8
  * - iOS: SwiftUI segmented `Picker` (system-styled).
9
9
  * - Android: a Material segmented control, accent-tinted via `tintColor`.
10
- * - Web: the vendored `@react-native-segmented-control/segmented-control`
11
- * JS implementation, accent-tinted via `tintColor`.
10
+ * - Web: the kit's own themed control (see `WebSegmentedControl`).
12
11
  *
13
12
  * The API is value-based to match the rest of the design system (RadioGroup /
14
13
  * Tabs / Select): pass the segment `values` plus a controlled `value` (or
15
14
  * `defaultValue` for uncontrolled), and read selections back as the chosen
16
- * string. A light haptic fires on each change, matching Slider / Switch.
15
+ * string. A selection haptic fires on each change, matching Switch / Checkbox
16
+ * (see `hapticSelection`).
17
17
  *
18
18
  * Theming: the accent color tints the selected segment on Android and web. iOS
19
19
  * draws the system segmented control, which ignores a custom tint — pass
20
20
  * `appearance` to force light/dark there.
21
21
  *
22
+ * Web: `@expo/ui`'s vendored web control paints every label white once a
23
+ * `tintColor` is passed, so unselected labels vanished on the light track and
24
+ * the disabled state was unreadable; it also exposes no label styling. The
25
+ * kit draws the control itself on web from theme tokens (`muted` track,
26
+ * accent pill, `accentForeground` / `mutedForeground` labels, the shared
27
+ * disabled opacity and focus ring). `appearance` is ignored there; the theme
28
+ * decides.
29
+ *
22
30
  * @example
23
31
  * ```tsx
24
32
  * <SegmentedControl
@@ -44,7 +52,7 @@ export interface SegmentedControlProps {
44
52
  * Applied on Android and web; iOS uses the system style.
45
53
  */
46
54
  tintColor?: string;
47
- /** Force a color scheme irrespective of the system theme. */
55
+ /** Force a color scheme irrespective of the system theme (iOS/Android; web follows the theme). */
48
56
  appearance?: "light" | "dark";
49
57
  /** Style override for the control. */
50
58
  style?: StyleProp<ViewStyle>;