@mrmeg/expo-ui 0.27.1 → 0.29.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 (155) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LLM_USAGE.md +241 -37
  3. package/README.md +591 -136
  4. package/bin/expo-ui-icons.mjs +509 -0
  5. package/dist/components/Accordion.js +12 -6
  6. package/dist/components/AnimatedView.d.ts +1 -1
  7. package/dist/components/AnimatedView.js +5 -2
  8. package/dist/components/Avatar.d.ts +1 -1
  9. package/dist/components/Avatar.js +2 -2
  10. package/dist/components/Badge.js +5 -1
  11. package/dist/components/BottomSheet.d.ts +29 -1
  12. package/dist/components/BottomSheet.js +107 -30
  13. package/dist/components/Button.d.ts +20 -4
  14. package/dist/components/Button.js +60 -43
  15. package/dist/components/Card.d.ts +1 -1
  16. package/dist/components/Card.js +24 -4
  17. package/dist/components/Carousel.js +2 -2
  18. package/dist/components/Checkbox.js +51 -44
  19. package/dist/components/Collapsible.js +7 -3
  20. package/dist/components/Dialog.js +87 -16
  21. package/dist/components/Drawer.d.ts +1 -1
  22. package/dist/components/Drawer.js +65 -55
  23. package/dist/components/DropdownMenu.js +11 -7
  24. package/dist/components/EmptyState.d.ts +2 -2
  25. package/dist/components/EmptyState.js +5 -2
  26. package/dist/components/Hydrated.d.ts +31 -0
  27. package/dist/components/Hydrated.js +30 -0
  28. package/dist/components/Icon.d.ts +71 -28
  29. package/dist/components/Icon.js +70 -28
  30. package/dist/components/IconGlyph.d.ts +15 -0
  31. package/dist/components/IconGlyph.js +32 -0
  32. package/dist/components/IconGlyph.native.d.ts +10 -0
  33. package/dist/components/IconGlyph.native.js +30 -0
  34. package/dist/components/InputOTP.js +5 -2
  35. package/dist/components/Item.d.ts +103 -4
  36. package/dist/components/Item.js +168 -15
  37. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  38. package/dist/components/KeyboardAvoidingView.js +4 -1
  39. package/dist/components/Label.js +3 -2
  40. package/dist/components/MaxWidthContainer.js +1 -1
  41. package/dist/components/Notification.js +45 -27
  42. package/dist/components/Popover.d.ts +18 -5
  43. package/dist/components/Popover.js +72 -8
  44. package/dist/components/Progress.js +8 -6
  45. package/dist/components/RadioGroup.js +52 -49
  46. package/dist/components/Screen.d.ts +61 -0
  47. package/dist/components/Screen.js +62 -0
  48. package/dist/components/SectionHeader.d.ts +1 -1
  49. package/dist/components/SegmentedControl.d.ts +12 -4
  50. package/dist/components/SegmentedControl.js +94 -4
  51. package/dist/components/Select.js +21 -26
  52. package/dist/components/Skeleton.js +24 -10
  53. package/dist/components/Slider.d.ts +11 -3
  54. package/dist/components/Slider.js +73 -6
  55. package/dist/components/StatCard.d.ts +4 -2
  56. package/dist/components/StatCard.js +3 -1
  57. package/dist/components/StyledText.d.ts +7 -1
  58. package/dist/components/StyledText.js +5 -3
  59. package/dist/components/Switch.d.ts +1 -1
  60. package/dist/components/Switch.js +20 -30
  61. package/dist/components/Tabs.d.ts +2 -2
  62. package/dist/components/Tabs.js +13 -24
  63. package/dist/components/TextInput.d.ts +1 -1
  64. package/dist/components/TextInput.js +71 -26
  65. package/dist/components/ThemeSelector.d.ts +52 -0
  66. package/dist/components/ThemeSelector.js +90 -0
  67. package/dist/components/Toggle.d.ts +5 -49
  68. package/dist/components/Toggle.js +53 -29
  69. package/dist/components/ToggleGroup.d.ts +4 -8
  70. package/dist/components/ToggleGroup.js +52 -34
  71. package/dist/components/Tooltip.d.ts +1 -1
  72. package/dist/components/Tooltip.js +9 -3
  73. package/dist/components/UIProvider.d.ts +22 -2
  74. package/dist/components/UIProvider.js +15 -3
  75. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  76. package/dist/components/bottomSheetDismiss.js +44 -0
  77. package/dist/components/iconBuiltins.generated.d.ts +143 -0
  78. package/dist/components/iconBuiltins.generated.js +25 -0
  79. package/dist/components/iconTypes.d.ts +37 -0
  80. package/dist/components/iconTypes.js +1 -0
  81. package/dist/components/index.d.ts +47 -44
  82. package/dist/components/index.js +3 -0
  83. package/dist/components/keyboardDismiss.js +12 -4
  84. package/dist/components/nativeTextField.android.js +13 -9
  85. package/dist/constants/colors.d.ts +40 -5
  86. package/dist/constants/colors.js +26 -8
  87. package/dist/constants/fonts.d.ts +41 -2
  88. package/dist/constants/fonts.js +46 -7
  89. package/dist/constants/index.d.ts +5 -4
  90. package/dist/constants/index.js +1 -0
  91. package/dist/constants/interaction.d.ts +18 -0
  92. package/dist/constants/interaction.js +18 -0
  93. package/dist/constants/spacing.d.ts +2 -1
  94. package/dist/constants/spacing.js +2 -1
  95. package/dist/design-system.json +429 -132
  96. package/dist/hooks/index.d.ts +12 -7
  97. package/dist/hooks/index.js +5 -0
  98. package/dist/hooks/useDimensions.d.ts +13 -8
  99. package/dist/hooks/useDimensions.js +123 -48
  100. package/dist/hooks/useFocusVisible.d.ts +30 -0
  101. package/dist/hooks/useFocusVisible.js +45 -0
  102. package/dist/hooks/useFontStyle.d.ts +2 -2
  103. package/dist/hooks/useFontStyle.js +3 -2
  104. package/dist/hooks/useHydrated.d.ts +13 -0
  105. package/dist/hooks/useHydrated.js +19 -0
  106. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  107. package/dist/hooks/useNotificationOffset.js +34 -0
  108. package/dist/hooks/useResources.d.ts +21 -20
  109. package/dist/hooks/useResources.js +72 -68
  110. package/dist/hooks/useScalePress.d.ts +8 -5
  111. package/dist/hooks/useScalePress.js +16 -9
  112. package/dist/hooks/useShape.d.ts +24 -0
  113. package/dist/hooks/useShape.js +24 -0
  114. package/dist/hooks/useStaggeredEntrance.js +9 -7
  115. package/dist/hooks/useTheme.d.ts +3 -3
  116. package/dist/hooks/useTheme.js +177 -66
  117. package/dist/hooks/useWindowInsets.d.ts +13 -0
  118. package/dist/hooks/useWindowInsets.js +23 -0
  119. package/dist/icons/LICENSE.lucide +43 -0
  120. package/dist/icons/lucide.json +2144 -0
  121. package/dist/index.d.ts +5 -5
  122. package/dist/lib/composeRefs.d.ts +8 -0
  123. package/dist/lib/composeRefs.js +20 -0
  124. package/dist/lib/haptics.d.ts +12 -0
  125. package/dist/lib/haptics.js +21 -0
  126. package/dist/lib/index.d.ts +6 -5
  127. package/dist/lib/index.js +1 -0
  128. package/dist/lib/interFonts.d.ts +15 -0
  129. package/dist/lib/interFonts.js +1 -0
  130. package/dist/lib/interFonts.native.d.ts +3 -0
  131. package/dist/lib/interFonts.native.js +20 -0
  132. package/dist/lib/popoverPlacement.d.ts +36 -0
  133. package/dist/lib/popoverPlacement.js +29 -0
  134. package/dist/lib/themedStyles.d.ts +1 -1
  135. package/dist/lib/useAnimatedValue.d.ts +15 -0
  136. package/dist/lib/useAnimatedValue.js +19 -0
  137. package/dist/lib/webHitTarget.d.ts +40 -0
  138. package/dist/lib/webHitTarget.js +37 -0
  139. package/dist/state/feedbackStore.d.ts +24 -0
  140. package/dist/state/feedbackStore.js +16 -0
  141. package/dist/state/globalUIStore.d.ts +27 -0
  142. package/dist/state/globalUIStore.js +23 -0
  143. package/dist/state/index.d.ts +7 -5
  144. package/dist/state/index.js +2 -0
  145. package/dist/state/initialScheme.d.ts +27 -0
  146. package/dist/state/initialScheme.js +29 -0
  147. package/dist/state/notify.d.ts +1 -1
  148. package/dist/state/themeColorScope.d.ts +1 -1
  149. package/dist/state/themeStore.d.ts +69 -16
  150. package/dist/state/themeStore.js +65 -38
  151. package/llms-full.md +59 -10
  152. package/llms.txt +10 -1
  153. package/package.json +79 -4
  154. package/dist/components/iconRegistry.generated.d.ts +0 -148
  155. package/dist/components/iconRegistry.generated.js +0 -296
@@ -61,6 +61,18 @@ import { ViewProps, StyleProp, ViewStyle, ScrollViewProps } from "react-native";
61
61
  * `keyboardShouldPersistTaps="always"` / `"handled"` on scroll views that
62
62
  * contain a sheet, or `DismissKeyboard`, which already sets it.
63
63
  *
64
+ * Dismiss completion: `onDismissed` (root prop) fires once per close after the
65
+ * sheet is fully gone, so the next modal (an RN `Modal`-backed `Dialog`, a
66
+ * native stack modal) can be presented from it without UIKit rejecting the
67
+ * presentation while the sheet is still animating out. iOS: `@expo/ui` raises
68
+ * its close callback from the native `onDismiss` event of SwiftUI
69
+ * `.sheet(isPresented:onDismiss:)`, which runs after the dismissal transition.
70
+ * Android: `@expo/ui` raises it after Material's hide animation for a swipe /
71
+ * back / scrim dismissal, and with the removal of the Compose sheet for a
72
+ * prop-driven close (which has no exit animation). Web: `@expo/ui` raises it
73
+ * before its exit animation, so the package waits for the HTML `<dialog>`'s
74
+ * `close` event instead (`bottomSheetDismiss.ts`).
75
+ *
64
76
  * Scrollable bodies: the native sheet doesn't bound the hosted RN content to
65
77
  * the detent height, so a tall `Body` overflows and clips its footer/tail. When
66
78
  * `Body` detects overflow, `Content` caps the column to the detent height so the
@@ -116,12 +128,28 @@ interface BottomSheetContextValue {
116
128
  /** Whether a `Footer` is mounted, so `Body` can own the bottom safe-area inset when it isn't. */
117
129
  hasFooter: boolean;
118
130
  setHasFooter: (present: boolean) => void;
131
+ /** Called by `Content` once the native sheet has fully dismissed; invokes the root's `onDismissed`. */
132
+ notifyDismissed: () => void;
119
133
  }
120
134
  interface BottomSheetProps {
121
135
  /** Controlled open state. Omit to use uncontrolled mode with `defaultOpen`. */
122
136
  open?: boolean;
123
137
  /** Callback when open state changes. */
124
138
  onOpenChange?: (open: boolean) => void;
139
+ /**
140
+ * Called once per close, after the sheet has fully dismissed on every
141
+ * platform. Present the next modal (a `Dialog`, a native stack modal) from
142
+ * here rather than from `onOpenChange(false)` or a timer: on iOS the
143
+ * `UISheetPresentationController` is still dismissing when `onOpenChange`
144
+ * fires, and a `Modal` presented then is rejected ("already presenting")
145
+ * without retry. Detection — iOS: `@expo/ui`'s native `onDismiss` event
146
+ * (SwiftUI `.sheet(onDismiss:)`, after the transition). Android: `@expo/ui`'s
147
+ * close callback (after Material's hide animation for swipe / back / scrim; a
148
+ * prop-driven close removes the Compose sheet with no exit animation). Web:
149
+ * the HTML `<dialog>`'s `close` event, which `@expo/ui` raises when its exit
150
+ * animation ends.
151
+ */
152
+ onDismissed?: () => void;
125
153
  /** Initial open state for uncontrolled mode. Default: false. */
126
154
  defaultOpen?: boolean;
127
155
  /** Snap point heights (px or percentage strings). Default: ["50%"]. */
@@ -195,7 +223,7 @@ interface BottomSheetCloseProps {
195
223
  */
196
224
  export declare const bottomSheetAncestorClaimWarning: string;
197
225
  declare function useBottomSheetContext(): BottomSheetContextValue;
198
- declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
226
+ declare function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, onDismissed, defaultOpen, snapPoints, closeOnBackdropPress, children, }: BottomSheetProps): React.JSX.Element;
199
227
  declare function BottomSheetTrigger({ asChild, children, style: styleOverride }: BottomSheetTriggerProps): React.JSX.Element;
200
228
  declare function BottomSheetContent({ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }: BottomSheetContentProps): React.JSX.Element;
201
229
  declare function BottomSheetHandle({ style }: BottomSheetHandleProps): React.JSX.Element;
@@ -1,16 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef } from "react";
2
+ import React, { createContext, use, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef } from "react";
3
3
  import { View, Pressable, StyleSheet, ScrollView, Platform, Animated, useWindowDimensions, } from "react-native";
4
4
  import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
5
- import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
5
+ import { useWindowInsets } from "../hooks/useWindowInsets.js";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
+ import { useShape } from "../hooks/useShape.js";
7
8
  import { spacing } from "../constants/spacing.js";
8
9
  import { useScalePress } from "../hooks/useScalePress.js";
9
- import { TextColorContext, TextClassContext } from "./StyledText.context";
10
+ import { TextColorContext, TextClassContext } from "./StyledText.context.js";
10
11
  import { Icon } from "./Icon.js";
11
12
  import { useAncestorClaimWarning, useKeyboardDismissResponder } from "./keyboardDismiss.js";
12
13
  import { warnIfBottomSheetHostDropsResize } from "./bottomSheetHostSupport.js";
14
+ import { awaitDialogClose } from "./bottomSheetDismiss.js";
13
15
  const DEFAULT_SNAP_POINTS = ["50%"];
16
+ /**
17
+ * UIKit keeps a `.large` sheet's top edge 10 pt below the top safe-area inset;
18
+ * SwiftUI's `.fraction()` detents are fractions of the height that remains.
19
+ */
20
+ const IOS_SHEET_TOP_GAP = 10;
21
+ /**
22
+ * `@expo/ui` pads the hosted RN column 16 pt below the native drag indicator
23
+ * (`paddingTop: 16` in its iOS `BottomSheet` when `handleComponent !== null`).
24
+ */
25
+ const IOS_HOST_HANDLE_PADDING = 16;
14
26
  function clampSnapIndex(index, maxSnapIndex) {
15
27
  return Math.min(Math.max(index, 0), maxSnapIndex);
16
28
  }
@@ -108,23 +120,23 @@ function useBottomSheetContext() {
108
120
  return context;
109
121
  }
110
122
  /**
111
- * Safe-area insets for content *inside* the sheet.
112
- *
113
- * The native sheet (SwiftUI `.sheet()` / Material `ModalBottomSheet`) is
114
- * presented outside the React tree's `SafeAreaProvider`, so `useSafeAreaInsets()`
115
- * reads all-zero in here. Fall back to `initialWindowMetrics` — the same trick
116
- * the app uses for its full-screen Modals — so bottom padding actually clears
117
- * the home indicator and the last row of a scroll body is reachable.
123
+ * Safe-area insets for content *inside* the sheet. The native sheet (SwiftUI
124
+ * `.sheet()` / Material `ModalBottomSheet`) is presented outside the React
125
+ * tree's `SafeAreaProvider`, and inside a tab screen the provider context even
126
+ * carries the tab bar (83 pt on an iPhone with a home indicator), so the raw
127
+ * context is the wrong number here. `useWindowInsets` gives the window's
128
+ * insets; `top` sizes the iOS detent cap below. Both native hosts already lay
129
+ * the column out inside the safe area — SwiftUI keeps `RNHostView` 34 pt above
130
+ * the home indicator, and Material3's `ModalBottomSheet` applies the system
131
+ * bars as `contentWindowInsets`, so on an edge-to-edge Pixel the hosted column
132
+ * ends exactly at the navigation bar's top — so `bottom` is 0 on iOS and
133
+ * Android: padding it again pushed the footer up by the inset (measured 64 dp
134
+ * under Save instead of 40 on a Pixel 6a) and squeezed the body. Web is the
135
+ * kit's own overlay inside the React tree and keeps the window's bottom inset.
118
136
  */
119
137
  function useSheetInsets() {
120
- const insets = useSafeAreaInsets();
121
- const fallback = initialWindowMetrics?.insets;
122
- return {
123
- top: insets.top || fallback?.top || 0,
124
- bottom: insets.bottom || fallback?.bottom || 0,
125
- left: insets.left || fallback?.left || 0,
126
- right: insets.right || fallback?.right || 0,
127
- };
138
+ const insets = useWindowInsets();
139
+ return Platform.OS === "web" ? insets : { ...insets, bottom: 0 };
128
140
  }
129
141
  /**
130
142
  * Interactive (pull-down / backdrop) dismiss is off only when the consumer
@@ -160,7 +172,7 @@ function SheetCloseButton({ style }) {
160
172
  {
161
173
  width: spacing.xl,
162
174
  height: spacing.xl,
163
- borderRadius: spacing.xl / 2,
175
+ borderRadius: spacing.radiusFull,
164
176
  alignItems: "center",
165
177
  justifyContent: "center",
166
178
  // Higher-contrast than `muted`: a solid `secondary` fill with a
@@ -176,7 +188,7 @@ function SheetCloseButton({ style }) {
176
188
  // ============================================================================
177
189
  // Root
178
190
  // ============================================================================
179
- function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
191
+ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenChange, onDismissed, defaultOpen = false, snapPoints = DEFAULT_SNAP_POINTS, closeOnBackdropPress = true, children, }) {
180
192
  const maxSnapIndex = Math.max(snapPoints.length - 1, 0);
181
193
  const [state, dispatch] = useReducer(bottomSheetRootReducer, undefined, () => createInitialRootState(defaultOpen, maxSnapIndex));
182
194
  const isControlled = controlledOpen !== undefined;
@@ -201,6 +213,16 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
201
213
  }
202
214
  }, [controlledOnOpenChange, isControlled, maxSnapIndex]);
203
215
  const toggle = useCallback(() => onOpenChange(!open), [onOpenChange, open]);
216
+ // Latest callback without re-creating the context value on every render.
217
+ // Synced after each commit (the sheet reports its dismissal from a native
218
+ // or DOM event, never during render) instead of written during render.
219
+ const onDismissedRef = useRef(onDismissed);
220
+ useLayoutEffect(() => {
221
+ onDismissedRef.current = onDismissed;
222
+ });
223
+ const notifyDismissed = useCallback(() => {
224
+ onDismissedRef.current?.();
225
+ }, []);
204
226
  const setScrollable = useCallback((scrollable) => {
205
227
  dispatch({ type: "setScrollable", scrollable });
206
228
  }, []);
@@ -225,6 +247,7 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
225
247
  setHasHeader,
226
248
  hasFooter: state.hasFooter,
227
249
  setHasFooter,
250
+ notifyDismissed,
228
251
  }), [
229
252
  open,
230
253
  onOpenChange,
@@ -240,6 +263,7 @@ function BottomSheetRoot({ open: controlledOpen, onOpenChange: controlledOnOpenC
240
263
  setHasHeader,
241
264
  state.hasFooter,
242
265
  setHasFooter,
266
+ notifyDismissed,
243
267
  ]);
244
268
  return (_jsx(BottomSheetContext.Provider, { value: contextValue, children: children }));
245
269
  }
@@ -267,11 +291,15 @@ function BottomSheetTrigger({ asChild, children, style: styleOverride }) {
267
291
  function BottomSheetContent({
268
292
  // Accepted-but-ignored ergonomics props (platform owns these behaviors):
269
293
  swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDrag: _dismissKeyboardOnDrag, backgroundStyle: backgroundStyleOverride, style: styleOverride, testID, children, }) {
270
- const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader } = useBottomSheetContext();
294
+ const { open, onOpenChange, snapPoints, snapIndex, setSnapIndex, hasHeader, notifyDismissed } = useBottomSheetContext();
271
295
  const { theme } = useTheme();
296
+ const sheetShape = useShape("sheet");
272
297
  const dismissDisabled = useDismissDisabled();
273
298
  const showClose = useShowClose();
274
299
  const { height: winH } = useWindowDimensions();
300
+ // Window insets (not the provider context, which reads zero inside the
301
+ // native sheet's window): the top inset sizes the iOS detent cap below.
302
+ const insets = useSheetInsets();
275
303
  // Tap-away keyboard dismissal inside the sheet. The native sheet (SwiftUI
276
304
  // `.sheet()` / Material `ModalBottomSheet`) hosts its RN children in a separate
277
305
  // native window via @expo/ui's `RNHostView`, OUTSIDE the app's
@@ -314,17 +342,30 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
314
342
  // with `imePadding()` and `RNHostView` re-reports its Compose size to the
315
343
  // shadow tree, which needs `expo-modules-core` >= 57.0.4 to always land
316
344
  // (expo/expo#47778) — see `warnIfBottomSheetHostDropsResize` above.
345
+ //
346
+ // The cap has to be the height SwiftUI really gives the sheet, not a share
347
+ // of the window. `@expo/ui` maps "50%" to `.fraction(0.5)`, a fraction of
348
+ // the *maximum* sheet height — the window minus the top safe-area inset
349
+ // minus UIKit's 10 pt gap above a `.large` sheet — and a numeric snap to
350
+ // `.height(n)`, clamped to that maximum. `@expo/ui` then pads the hosted
351
+ // column 16 pt below the native grabber whenever that grabber is shown. A
352
+ // cap of `fraction × window height` overshot by `fraction × (inset + 10) +
353
+ // 16` ≈ 52 pt on a Dynamic Island phone at 50 %, so the sheet clipped its
354
+ // last row on every such device.
355
+ const hasInteractiveHandle = containsHandle(children);
317
356
  const expandedSnap = snapPoints[snapPoints.length - 1];
318
- const detentHeight = typeof expandedSnap === "number"
319
- ? expandedSnap
320
- : (parseFloat(expandedSnap) / 100) * winH;
357
+ const availableSheetHeight = winH - insets.top - IOS_SHEET_TOP_GAP;
358
+ const sheetHeight = typeof expandedSnap === "number"
359
+ ? Math.min(expandedSnap, availableSheetHeight)
360
+ : (parseFloat(expandedSnap) / 100) * availableSheetHeight;
361
+ const detentHeight = sheetHeight - (hasInteractiveHandle ? 0 : IOS_HOST_HANDLE_PADDING);
321
362
  // When there's no Header to host the X (so a close affordance is wanted —
322
363
  // dismiss is off, or the body scrolls), float one over the top-right corner.
323
364
  // A Header, when present, renders its own (see BottomSheetHeader).
324
365
  const showFloatingClose = showClose && !hasHeader;
325
- const hasInteractiveHandle = containsHandle(children);
326
366
  const handleChange = (newIndex) => {
327
367
  // Native fires onChange(-1) on dismiss (swipe / backdrop / back button).
368
+ // The same close also reaches `handleNativeClose`, which owns `onDismissed`.
328
369
  if (newIndex < 0) {
329
370
  if (open)
330
371
  onOpenChange(false);
@@ -332,21 +373,57 @@ swipeEnabled: _swipeEnabled, avoidKeyboard: _avoidKeyboard, dismissKeyboardOnDra
332
373
  }
333
374
  setSnapIndex(newIndex);
334
375
  };
376
+ // `onDismissed`: once per close. `@expo/ui` fires `onClose` once per close
377
+ // itself (post-dismissal on iOS and Android, see the header); the guard here
378
+ // is defensive and is re-armed when the sheet opens again. On web the close
379
+ // callback precedes the exit animation, so completion is the enclosing
380
+ // `<dialog>`'s `close` event, found from the content column's DOM node.
381
+ const columnRef = useRef(null);
382
+ const dismissedRef = useRef(false);
383
+ const cancelDismissWaitRef = useRef(null);
384
+ useEffect(() => {
385
+ if (open)
386
+ dismissedRef.current = false;
387
+ }, [open]);
388
+ useEffect(() => () => {
389
+ cancelDismissWaitRef.current?.();
390
+ cancelDismissWaitRef.current = null;
391
+ }, []);
392
+ const handleNativeClose = () => {
393
+ if (open)
394
+ onOpenChange(false);
395
+ if (dismissedRef.current)
396
+ return;
397
+ dismissedRef.current = true;
398
+ if (Platform.OS !== "web") {
399
+ notifyDismissed();
400
+ return;
401
+ }
402
+ cancelDismissWaitRef.current?.();
403
+ cancelDismissWaitRef.current = awaitDialogClose(columnRef.current, () => {
404
+ cancelDismissWaitRef.current = null;
405
+ notifyDismissed();
406
+ });
407
+ };
335
408
  return (_jsx(NativeBottomSheet, { index: index, snapPoints: snapPoints,
336
409
  // The native indicator lives outside the hosted RN tree and cannot
337
410
  // receive JS presses. Hide it only when the compound Handle supplies the
338
411
  // interactive replacement; sheets without Handle retain native chrome.
339
- handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: () => {
340
- if (open)
341
- onOpenChange(false);
342
- },
412
+ handleComponent: hasInteractiveHandle ? null : undefined, enablePanDownToClose: !dismissDisabled, onChange: handleChange, onClose: handleNativeClose,
343
413
  // Themes the scrim/background on web (vaul), Android (containerColor),
344
414
  // and iOS (presentationBackground). Flattened so native readers that
345
415
  // expect a plain object (not a style array) keep working.
346
416
  backgroundStyle: StyleSheet.flatten([
347
417
  { backgroundColor: theme.colors.card },
418
+ // Host-app `setShape({ sheet })`: the top corners, where the platform
419
+ // lets the sheet draw them (web's drawer, Android). iOS system sheets
420
+ // keep the system corner radius.
421
+ sheetShape?.borderRadius !== undefined && {
422
+ borderTopLeftRadius: sheetShape.borderRadius,
423
+ borderTopRightRadius: sheetShape.borderRadius,
424
+ },
348
425
  backgroundStyleOverride,
349
- ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { testID: testID, style: [
426
+ ]), children: _jsx(TextColorContext.Provider, { value: theme.colors.foreground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsxs(View, { ref: columnRef, testID: testID, style: [
350
427
  // No fill of its own: the native surface (`backgroundStyle`) is
351
428
  // the sheet's only background. On iOS the hosted column starts
352
429
  // 16 pt below the sheet's top edge when the native grabber is
@@ -1,7 +1,7 @@
1
1
  import React, { ComponentType } from "react";
2
- import { PressableProps, PressableStateCallbackType, DimensionValue, StyleProp, TextStyle, ViewStyle } from "react-native";
3
- import { TextProps } from "./StyledText";
4
- import { type IconProps } from "./Icon";
2
+ import { Pressable, PressableProps, PressableStateCallbackType, DimensionValue, StyleProp, TextStyle, ViewStyle } from "react-native";
3
+ import { TextProps } from "./StyledText.js";
4
+ import { type IconProps } from "./Icon.js";
5
5
  /**
6
6
  * Button variants
7
7
  */
@@ -27,6 +27,12 @@ export interface ButtonAccessoryProps {
27
27
  disabled?: boolean;
28
28
  }
29
29
  export interface ButtonProps extends PressableProps {
30
+ /**
31
+ * Reaches the underlying `Pressable`. React 19 passes `ref` as a prop; the
32
+ * button composes it with its own host ref (web `aria-disabled`), so
33
+ * `asChild` slots and consumers keep theirs.
34
+ */
35
+ ref?: React.Ref<React.ComponentRef<typeof Pressable>>;
30
36
  /**
31
37
  * Text which is looked up via i18n.
32
38
  */
@@ -82,13 +88,23 @@ export interface ButtonProps extends PressableProps {
82
88
  */
83
89
  children?: React.ReactNode;
84
90
  /**
85
- * disabled prop, accessed directly for declarative styling reasons.
91
+ * Blocks presses: no `onPress`, no scale, no haptic, dimmed, and out of the
92
+ * web tab order. To announce and dim a button that must stay pressable so
93
+ * the press can say why the action is unavailable ("Add a title first"),
94
+ * pass `aria-disabled` (or `accessibilityState={{ disabled: true }}`)
95
+ * instead — ARIA's contract: announced as disabled, still focusable, still
96
+ * fires `onPress`.
86
97
  */
87
98
  disabled?: boolean;
88
99
  /**
89
100
  * An optional style override for the disabled state
90
101
  */
91
102
  disabledStyle?: StyleProp<ViewStyle>;
103
+ /**
104
+ * Haptic on press-in. Omit it to follow the provider-level `haptics` setting
105
+ * (a light tap only under `"all"`); `true` always taps, `false` never does.
106
+ */
107
+ haptic?: boolean;
92
108
  /**
93
109
  * Whether to show shadow.
94
110
  * @default true for the `default` preset, `false` for every other preset.
@@ -1,17 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { use, useCallback, useMemo, useState } from "react";
2
+ import { use, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { Pressable, StyleSheet, View, Platform, ActivityIndicator, Animated, } from "react-native";
4
4
  import { spacing } from "../constants/spacing.js";
5
+ import { interaction } from "../constants/interaction.js";
5
6
  import { StyledText } from "./StyledText.js";
6
7
  import { Icon } from "./Icon.js";
7
- import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
8
+ import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context.js";
8
9
  import { palette } from "../constants/colors.js";
9
10
  import { useTheme } from "../hooks/useTheme.js";
11
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
10
12
  import { useFontStyle } from "../hooks/useFontStyle.js";
11
13
  import { useScalePress } from "../hooks/useScalePress.js";
12
14
  import { useThemeStore } from "../state/themeStore.js";
13
15
  import { createThemedStyles } from "../lib/themedStyles.js";
14
16
  import { stateSurfaceProps } from "../lib/stateSurface.js";
17
+ import { composeRefs } from "../lib/composeRefs.js";
18
+ import { getWebHitInsets, verticalSlopFor, WebHitTarget } from "../lib/webHitTarget.js";
15
19
  const SIZE_CONFIGS = {
16
20
  sm: {
17
21
  paddingVertical: spacing.xxs,
@@ -32,7 +36,9 @@ const SIZE_CONFIGS = {
32
36
  height: 40,
33
37
  },
34
38
  };
35
- const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarget - sizeConfig.height) / 2);
39
+ /** `outline` border. The web hit extender adds it back (see `WebHitTarget`). */
40
+ const OUTLINE_BORDER_WIDTH = 1;
41
+ const getNativeHitSlop = (sizeConfig) => verticalSlopFor(sizeConfig.height);
36
42
  /**
37
43
  * Enhanced Button Component
38
44
  *
@@ -73,7 +79,7 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
73
79
  * ```
74
80
  */
75
81
  function ButtonRoot(props) {
76
- const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow: withShadowProp, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, ...rest } = props;
82
+ const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow: withShadowProp, haptic, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, accessibilityState, "aria-disabled": ariaDisabled, ref: forwardedRef, ...rest } = props;
77
83
  const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
78
84
  const styles = themedStyles(theme)[size];
79
85
  const shadowStyle = getShadowStyle("subtle");
@@ -129,38 +135,39 @@ function ButtonRoot(props) {
129
135
  : preset === "secondary"
130
136
  ? theme.colors.secondaryForeground
131
137
  : getContrastingColor(backgroundColor, palette.white, palette.black);
132
- const [focused, setFocused] = useState(false);
133
138
  const [restingWidth, setRestingWidth] = useState();
134
- const isDisabled = disabled || loading;
139
+ // Two kinds of disabled. `pressBlocked` (the `disabled` prop, or loading)
140
+ // swallows presses. `isDisabled` is what is ANNOUNCED and drawn: that, or a
141
+ // consumer `aria-disabled` / `accessibilityState.disabled`, which keeps the
142
+ // button focusable and pressable so the press can explain itself.
143
+ const pressBlocked = !!disabled || loading;
144
+ // Web only: see `lib/webHitTarget`. Native keeps `hitSlop` on the Pressable.
145
+ const webHitInsets = Platform.OS === "web" ? getWebHitInsets(rest.hitSlop, sizeConfig.height) : null;
146
+ const hitBorder = preset === "outline" ? OUTLINE_BORDER_WIDTH : 0;
147
+ const isDisabled = pressBlocked || (ariaDisabled ?? accessibilityState?.disabled) === true;
135
148
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
136
149
  disabled: !!isDisabled,
137
- haptic: false,
138
- scaleTo: preset === "link" ? 1 : 0.97,
150
+ haptic: haptic ?? "setting",
151
+ scaleTo: preset === "link" ? 1 : interaction.pressedScale,
139
152
  });
140
- const showFocusRing = (event) => {
141
- // On web, pointer taps focus the element too, which left the ring visible
142
- // after every click. :focus-visible is only true for keyboard-driven
143
- // focus, so gate the ring on it; fall back to showing the ring when the
144
- // target can't be queried (non-DOM targets, older engines).
145
- let ringVisible = true;
146
- if (Platform.OS === "web") {
147
- const target = event?.nativeEvent?.target;
148
- if (target && typeof target.matches === "function") {
149
- try {
150
- ringVisible = target.matches(":focus-visible");
151
- }
152
- catch {
153
- ringVisible = true;
154
- }
155
- }
156
- }
157
- setFocused(ringVisible);
158
- onFocus?.(event);
159
- };
160
- const hideFocusRing = (event) => {
161
- setFocused(false);
162
- onBlur?.(event);
163
- };
153
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible({ onFocus, onBlur });
154
+ const hostRef = useRef(null);
155
+ const pressBlockedRef = useRef(pressBlocked);
156
+ pressBlockedRef.current = pressBlocked;
157
+ const announceOnly = isDisabled && !pressBlocked;
158
+ useLayoutEffect(() => {
159
+ if (Platform.OS !== "web" || !announceOnly)
160
+ return;
161
+ const node = hostRef.current;
162
+ if (!node?.setAttribute)
163
+ return;
164
+ node.setAttribute("aria-disabled", "true");
165
+ return () => {
166
+ if (!pressBlockedRef.current)
167
+ node.removeAttribute?.("aria-disabled");
168
+ };
169
+ }, [announceOnly]);
170
+ const composedRef = useMemo(() => composeRefs(hostRef, forwardedRef), [forwardedRef]);
164
171
  const handlePressIn = (event) => {
165
172
  pressHandlers.onPressIn();
166
173
  onPressIn?.(event);
@@ -169,6 +176,12 @@ function ButtonRoot(props) {
169
176
  pressHandlers.onPressOut();
170
177
  onPressOut?.(event);
171
178
  };
179
+ // The resting width is only read while `loading`, to keep the button from
180
+ // resizing when its label changes under the spinner (the label stays
181
+ // mounted at opacity 0, so an unchanged label already keeps the width). A
182
+ // button that is never given a `loading` prop can't enter that state, so it
183
+ // skips the layout callback and the extra render it caused on every mount.
184
+ const measuresRestingWidth = props.loading !== undefined && !fullWidth;
172
185
  const handleButtonLayout = useCallback((event) => {
173
186
  if (loading || fullWidth)
174
187
  return;
@@ -182,7 +195,10 @@ function ButtonRoot(props) {
182
195
  return nextWidth;
183
196
  });
184
197
  }, [fullWidth, loading]);
185
- return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: labelTextStyle, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View
198
+ return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: labelTextStyle, children: _jsx(Pressable, { ref: composedRef, accessibilityRole: "button", accessibilityState: { ...accessibilityState, disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)),
199
+ // Never `false`: RN's Pressable would overwrite a consumer
200
+ // `accessibilityState.disabled` with it.
201
+ disabled: pressBlocked || undefined, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View
186
202
  // Android: fixed native tree across disabled/pressed/loading
187
203
  // flips (see `stateSurfaceProps`).
188
204
  , { ...stateSurfaceProps(), style: [
@@ -205,11 +221,12 @@ function ButtonRoot(props) {
205
221
  state.pressed && pressedStyleOverride,
206
222
  isDisabled && styles.disabled,
207
223
  isDisabled && disabledStyleOverride,
208
- focused && !isDisabled && focusRingStyle,
224
+ // An announce-only disabled button is still focusable: show its ring.
225
+ focused && !pressBlocked && focusRingStyle,
209
226
  loading && restingWidth !== undefined && !fullWidth && { width: restingWidth },
210
227
  // Spread array styles from Slot to prevent nested arrays on web
211
228
  ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
212
- ], onLayout: handleButtonLayout, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { ...stateSurfaceProps(), style: [styles.content, loading && styles.loadingContent, { pointerEvents: loading ? "none" : "auto" }], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
229
+ ], onLayout: measuresRestingWidth ? handleButtonLayout : undefined, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { ...stateSurfaceProps(), style: [styles.content, loading && styles.loadingContent, { pointerEvents: loading ? "none" : "auto" }], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
213
230
  labelTextStyle,
214
231
  state.pressed && styles.pressedText,
215
232
  state.pressed && pressedTextStyleOverride,
@@ -219,7 +236,7 @@ function ButtonRoot(props) {
219
236
  // Children render as-is. For text content, use the explicit
220
237
  // <Button.Text> subcomponent so it inherits control typography
221
238
  // via context instead of relying on a runtime `typeof` check.
222
- children ?? null), !!RightAccessory && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] })] }) })) }) }) }) }));
239
+ children ?? null), !!RightAccessory && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] }), _jsx(WebHitTarget, { insets: webHitInsets, border: hitBorder, testID: "button-hit-target" })] }) })) }) }) }) }));
223
240
  }
224
241
  /**
225
242
  * Button.Text
@@ -287,7 +304,7 @@ const createStyles = (theme, size) => {
287
304
  },
288
305
  buttonOutline: {
289
306
  backgroundColor: "transparent",
290
- borderWidth: 1,
307
+ borderWidth: OUTLINE_BORDER_WIDTH,
291
308
  borderColor: theme.colors.input,
292
309
  },
293
310
  buttonGhost: {
@@ -313,16 +330,16 @@ const createStyles = (theme, size) => {
313
330
  userSelect: "none",
314
331
  },
315
332
  pressed: {
316
- opacity: 0.9,
333
+ opacity: interaction.pressedOpacity,
317
334
  },
318
335
  pressedMuted: {
319
336
  backgroundColor: theme.colors.muted,
320
337
  },
321
- pressedText: {
322
- opacity: 0.9,
323
- },
338
+ // The container's pressed opacity already dims the label; a second layer
339
+ // here would compound it.
340
+ pressedText: {},
324
341
  disabled: {
325
- opacity: 0.6,
342
+ opacity: interaction.disabledOpacity,
326
343
  },
327
344
  leftAccessory: {
328
345
  marginRight: spacing.sm,
@@ -337,7 +354,7 @@ const createStyles = (theme, size) => {
337
354
  },
338
355
  });
339
356
  };
340
- const themedStyles = createThemedStyles((theme) => ({
357
+ const themedStyles = /*#__PURE__*/ createThemedStyles((theme) => ({
341
358
  sm: createStyles(theme, "sm"),
342
359
  md: createStyles(theme, "md"),
343
360
  lg: createStyles(theme, "lg"),
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { ViewStyle, TextStyle, StyleProp } from "react-native";
3
- import { TextProps } from "./StyledText";
3
+ import { TextProps } from "./StyledText.js";
4
4
  export interface CardProps {
5
5
  /** Card contents */
6
6
  children?: React.ReactNode;
@@ -3,8 +3,11 @@ import { createContext, use } from "react";
3
3
  import { View, Pressable, StyleSheet, Platform, Animated } from "react-native";
4
4
  import { StyledText } from "./StyledText.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
+ import { useShape } from "../hooks/useShape.js";
6
7
  import { useScalePress } from "../hooks/useScalePress.js";
8
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
7
9
  import { spacing } from "../constants/spacing.js";
10
+ import { interaction } from "../constants/interaction.js";
8
11
  import { createThemedStyles } from "../lib/themedStyles.js";
9
12
  /**
10
13
  * Card Component
@@ -12,6 +15,13 @@ import { createThemedStyles } from "../lib/themedStyles.js";
12
15
  * A themed container component with header, content, and footer sections.
13
16
  * Follows shadcn/ui patterns with consistent styling and theme integration.
14
17
  *
18
+ * Use a Card for one item in a collection (a feed entry, a grid tile, a
19
+ * carousel slide) or for a single tappable object. It is not a layout box:
20
+ * don't wrap a screen section, a form, or a group of rows in one (use
21
+ * `ItemGroup`, `SectionHeader`, or a `Separator`), and never nest Cards. The
22
+ * parts pad `spacing.cardPadding`, so a Card inside a padded box doubles the
23
+ * inset.
24
+ *
15
25
  * Usage:
16
26
  * ```tsx
17
27
  * <Card>
@@ -38,17 +48,20 @@ function useCardContext() {
38
48
  return ctx ?? { theme, styles: themedStyles(theme) };
39
49
  }
40
50
  function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
41
- const { theme, getShadowStyle } = useTheme();
51
+ const { theme, getShadowStyle, getFocusRingStyle } = useTheme();
42
52
  const styles = themedStyles(theme);
43
53
  const shadowStyle = getShadowStyle("subtle");
54
+ // Host-app `setShape({ card })`, on the surface and on the pressable ring wrapper.
55
+ const cardRadius = useShape("card")?.borderRadius ?? spacing.radiusLg;
44
56
  const ctx = { theme, styles };
45
57
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
46
58
  disabled: !onPress || !!disabled,
47
59
  scaleTo: 0.98,
48
- haptic: false,
49
60
  });
61
+ const focus = useFocusVisible();
50
62
  const cardContent = (_jsx(View, { style: [
51
63
  styles.card,
64
+ { borderRadius: cardRadius },
52
65
  variant === "default" && styles.cardDefault,
53
66
  variant === "default" && shadowStyle,
54
67
  variant === "outline" && styles.cardOutline,
@@ -56,7 +69,14 @@ function Card({ children, style: styleOverride, variant = "default", onPress, di
56
69
  styleOverride,
57
70
  ], children: children }));
58
71
  if (onPress) {
59
- return (_jsx(CardContext.Provider, { value: ctx, children: _jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityState: { disabled: !!disabled }, ...pressHandlers, style: Platform.OS === "web" ? { cursor: "pointer" } : undefined, children: _jsx(Animated.View, { style: scaleStyle, children: cardContent }) }) }));
72
+ return (_jsx(CardContext.Provider, { value: ctx, children: _jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityState: { disabled: !!disabled }, ...pressHandlers, onFocus: focus.onFocus, onBlur: focus.onBlur, style: ({ pressed }) => [
73
+ // The ring is a box shadow on this wrapper, so it needs the card's
74
+ // radius; the browser outline is off because the ring replaces it.
75
+ { borderRadius: cardRadius },
76
+ Platform.OS === "web" && { cursor: "pointer", outlineStyle: "none" },
77
+ pressed && { opacity: interaction.pressedOpacity },
78
+ focus.focused && !disabled && getFocusRingStyle(),
79
+ ], children: _jsx(Animated.View, { style: scaleStyle, children: cardContent }) }) }));
60
80
  }
61
81
  return (_jsx(CardContext.Provider, { value: ctx, children: cardContent }));
62
82
  }
@@ -123,5 +143,5 @@ const createCardStyles = (theme) => StyleSheet.create({
123
143
  lineHeight: 20,
124
144
  },
125
145
  });
126
- const themedStyles = createThemedStyles(createCardStyles);
146
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createCardStyles);
127
147
  export { Card, CardHeader, CardContent, CardFooter, CardTitle, CardDescription };
@@ -197,7 +197,7 @@ const DOT_HIT_SLOP = {
197
197
  // site (it tracks the contentPadding prop) so the leading inset survives the
198
198
  // browser snapping slide 0 to the scrollport start — which also makes page `i`
199
199
  // land on `i * interval`, matching the native snap offsets.
200
- const webStyles = StyleSheet.create({
200
+ const webStyles = /*#__PURE__*/ StyleSheet.create({
201
201
  scroller: { scrollSnapType: "x mandatory" },
202
202
  item: { scrollSnapAlign: "start" },
203
203
  });
@@ -245,4 +245,4 @@ const createStyles = (theme) => StyleSheet.create({
245
245
  backgroundColor: theme.colors.border,
246
246
  },
247
247
  });
248
- const themedStyles = createThemedStyles(createStyles);
248
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);