@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.
- package/CHANGELOG.md +666 -0
- package/LLM_USAGE.md +241 -37
- package/README.md +591 -136
- package/bin/expo-ui-icons.mjs +509 -0
- package/dist/components/Accordion.js +12 -6
- package/dist/components/AnimatedView.d.ts +1 -1
- package/dist/components/AnimatedView.js +5 -2
- package/dist/components/Avatar.d.ts +1 -1
- package/dist/components/Avatar.js +2 -2
- package/dist/components/Badge.js +5 -1
- package/dist/components/BottomSheet.d.ts +29 -1
- package/dist/components/BottomSheet.js +107 -30
- package/dist/components/Button.d.ts +20 -4
- package/dist/components/Button.js +60 -43
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +24 -4
- package/dist/components/Carousel.js +2 -2
- package/dist/components/Checkbox.js +51 -44
- package/dist/components/Collapsible.js +7 -3
- package/dist/components/Dialog.js +87 -16
- package/dist/components/Drawer.d.ts +1 -1
- package/dist/components/Drawer.js +65 -55
- package/dist/components/DropdownMenu.js +11 -7
- package/dist/components/EmptyState.d.ts +2 -2
- package/dist/components/EmptyState.js +5 -2
- package/dist/components/Hydrated.d.ts +31 -0
- package/dist/components/Hydrated.js +30 -0
- package/dist/components/Icon.d.ts +71 -28
- package/dist/components/Icon.js +70 -28
- package/dist/components/IconGlyph.d.ts +15 -0
- package/dist/components/IconGlyph.js +32 -0
- package/dist/components/IconGlyph.native.d.ts +10 -0
- package/dist/components/IconGlyph.native.js +30 -0
- package/dist/components/InputOTP.js +5 -2
- package/dist/components/Item.d.ts +103 -4
- package/dist/components/Item.js +168 -15
- package/dist/components/KeyboardAvoidingView.d.ts +1 -1
- package/dist/components/KeyboardAvoidingView.js +4 -1
- package/dist/components/Label.js +3 -2
- package/dist/components/MaxWidthContainer.js +1 -1
- package/dist/components/Notification.js +45 -27
- package/dist/components/Popover.d.ts +18 -5
- package/dist/components/Popover.js +72 -8
- package/dist/components/Progress.js +8 -6
- package/dist/components/RadioGroup.js +52 -49
- package/dist/components/Screen.d.ts +61 -0
- package/dist/components/Screen.js +62 -0
- package/dist/components/SectionHeader.d.ts +1 -1
- package/dist/components/SegmentedControl.d.ts +12 -4
- package/dist/components/SegmentedControl.js +94 -4
- package/dist/components/Select.js +21 -26
- package/dist/components/Skeleton.js +24 -10
- package/dist/components/Slider.d.ts +11 -3
- package/dist/components/Slider.js +73 -6
- package/dist/components/StatCard.d.ts +4 -2
- package/dist/components/StatCard.js +3 -1
- package/dist/components/StyledText.d.ts +7 -1
- package/dist/components/StyledText.js +5 -3
- package/dist/components/Switch.d.ts +1 -1
- package/dist/components/Switch.js +20 -30
- package/dist/components/Tabs.d.ts +2 -2
- package/dist/components/Tabs.js +13 -24
- package/dist/components/TextInput.d.ts +1 -1
- package/dist/components/TextInput.js +71 -26
- package/dist/components/ThemeSelector.d.ts +52 -0
- package/dist/components/ThemeSelector.js +90 -0
- package/dist/components/Toggle.d.ts +5 -49
- package/dist/components/Toggle.js +53 -29
- package/dist/components/ToggleGroup.d.ts +4 -8
- package/dist/components/ToggleGroup.js +52 -34
- package/dist/components/Tooltip.d.ts +1 -1
- package/dist/components/Tooltip.js +9 -3
- package/dist/components/UIProvider.d.ts +22 -2
- package/dist/components/UIProvider.js +15 -3
- package/dist/components/bottomSheetDismiss.d.ts +19 -0
- package/dist/components/bottomSheetDismiss.js +44 -0
- package/dist/components/iconBuiltins.generated.d.ts +143 -0
- package/dist/components/iconBuiltins.generated.js +25 -0
- package/dist/components/iconTypes.d.ts +37 -0
- package/dist/components/iconTypes.js +1 -0
- package/dist/components/index.d.ts +47 -44
- package/dist/components/index.js +3 -0
- package/dist/components/keyboardDismiss.js +12 -4
- package/dist/components/nativeTextField.android.js +13 -9
- package/dist/constants/colors.d.ts +40 -5
- package/dist/constants/colors.js +26 -8
- package/dist/constants/fonts.d.ts +41 -2
- package/dist/constants/fonts.js +46 -7
- package/dist/constants/index.d.ts +5 -4
- package/dist/constants/index.js +1 -0
- package/dist/constants/interaction.d.ts +18 -0
- package/dist/constants/interaction.js +18 -0
- package/dist/constants/spacing.d.ts +2 -1
- package/dist/constants/spacing.js +2 -1
- package/dist/design-system.json +429 -132
- package/dist/hooks/index.d.ts +12 -7
- package/dist/hooks/index.js +5 -0
- package/dist/hooks/useDimensions.d.ts +13 -8
- package/dist/hooks/useDimensions.js +123 -48
- package/dist/hooks/useFocusVisible.d.ts +30 -0
- package/dist/hooks/useFocusVisible.js +45 -0
- package/dist/hooks/useFontStyle.d.ts +2 -2
- package/dist/hooks/useFontStyle.js +3 -2
- package/dist/hooks/useHydrated.d.ts +13 -0
- package/dist/hooks/useHydrated.js +19 -0
- package/dist/hooks/useNotificationOffset.d.ts +20 -0
- package/dist/hooks/useNotificationOffset.js +34 -0
- package/dist/hooks/useResources.d.ts +21 -20
- package/dist/hooks/useResources.js +72 -68
- package/dist/hooks/useScalePress.d.ts +8 -5
- package/dist/hooks/useScalePress.js +16 -9
- package/dist/hooks/useShape.d.ts +24 -0
- package/dist/hooks/useShape.js +24 -0
- package/dist/hooks/useStaggeredEntrance.js +9 -7
- package/dist/hooks/useTheme.d.ts +3 -3
- package/dist/hooks/useTheme.js +177 -66
- package/dist/hooks/useWindowInsets.d.ts +13 -0
- package/dist/hooks/useWindowInsets.js +23 -0
- package/dist/icons/LICENSE.lucide +43 -0
- package/dist/icons/lucide.json +2144 -0
- package/dist/index.d.ts +5 -5
- package/dist/lib/composeRefs.d.ts +8 -0
- package/dist/lib/composeRefs.js +20 -0
- package/dist/lib/haptics.d.ts +12 -0
- package/dist/lib/haptics.js +21 -0
- package/dist/lib/index.d.ts +6 -5
- package/dist/lib/index.js +1 -0
- package/dist/lib/interFonts.d.ts +15 -0
- package/dist/lib/interFonts.js +1 -0
- package/dist/lib/interFonts.native.d.ts +3 -0
- package/dist/lib/interFonts.native.js +20 -0
- package/dist/lib/popoverPlacement.d.ts +36 -0
- package/dist/lib/popoverPlacement.js +29 -0
- package/dist/lib/themedStyles.d.ts +1 -1
- package/dist/lib/useAnimatedValue.d.ts +15 -0
- package/dist/lib/useAnimatedValue.js +19 -0
- package/dist/lib/webHitTarget.d.ts +40 -0
- package/dist/lib/webHitTarget.js +37 -0
- package/dist/state/feedbackStore.d.ts +24 -0
- package/dist/state/feedbackStore.js +16 -0
- package/dist/state/globalUIStore.d.ts +27 -0
- package/dist/state/globalUIStore.js +23 -0
- package/dist/state/index.d.ts +7 -5
- package/dist/state/index.js +2 -0
- package/dist/state/initialScheme.d.ts +27 -0
- package/dist/state/initialScheme.js +29 -0
- package/dist/state/notify.d.ts +1 -1
- package/dist/state/themeColorScope.d.ts +1 -1
- package/dist/state/themeStore.d.ts +69 -16
- package/dist/state/themeStore.js +65 -38
- package/llms-full.md +59 -10
- package/llms.txt +10 -1
- package/package.json +79 -4
- package/dist/components/iconRegistry.generated.d.ts +0 -148
- 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 {
|
|
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
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* the
|
|
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 =
|
|
121
|
-
|
|
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.
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
138
|
-
scaleTo: preset === "link" ? 1 :
|
|
150
|
+
haptic: haptic ?? "setting",
|
|
151
|
+
scaleTo: preset === "link" ? 1 : interaction.pressedScale,
|
|
139
152
|
});
|
|
140
|
-
const showFocusRing = (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (Platform.OS
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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)),
|
|
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
|
-
|
|
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:
|
|
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:
|
|
333
|
+
opacity: interaction.pressedOpacity,
|
|
317
334
|
},
|
|
318
335
|
pressedMuted: {
|
|
319
336
|
backgroundColor: theme.colors.muted,
|
|
320
337
|
},
|
|
321
|
-
|
|
322
|
-
|
|
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:
|
|
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"),
|
package/dist/components/Card.js
CHANGED
|
@@ -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,
|
|
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);
|