@mrmeg/expo-ui 0.27.1 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +598 -0
- package/LLM_USAGE.md +233 -35
- package/README.md +504 -103
- 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 +17 -5
- package/dist/components/Icon.js +31 -10
- 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/Toggle.d.ts +2 -42
- package/dist/components/Toggle.js +38 -27
- package/dist/components/ToggleGroup.d.ts +1 -1
- package/dist/components/ToggleGroup.js +37 -32
- 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/index.d.ts +46 -44
- package/dist/components/index.js +2 -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 +154 -2
- 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/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 +56 -8
- package/llms.txt +10 -1
- package/package.json +69 -2
|
@@ -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);
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useCallback, useEffect
|
|
2
|
+
import { useCallback, useEffect } from "react";
|
|
3
3
|
import { View, StyleSheet, Pressable, Platform, Animated } from "react-native";
|
|
4
4
|
import { Icon } from "./Icon.js";
|
|
5
5
|
import { StyledText } from "./StyledText.js";
|
|
6
6
|
import { useTheme } from "../hooks/useTheme.js";
|
|
7
|
+
import { useFocusVisible } from "../hooks/useFocusVisible.js";
|
|
7
8
|
import { spacing } from "../constants/spacing.js";
|
|
8
|
-
import {
|
|
9
|
+
import { hapticSelection } from "../lib/haptics.js";
|
|
10
|
+
import { interaction } from "../constants/interaction.js";
|
|
11
|
+
import { useAnimatedValue } from "../lib/useAnimatedValue.js";
|
|
9
12
|
import { useReducedMotion } from "../hooks/useReduceMotion.js";
|
|
10
13
|
import { useScalePress } from "../hooks/useScalePress.js";
|
|
14
|
+
import { shouldUseNativeDriver } from "../lib/animations.js";
|
|
11
15
|
import * as CheckboxPrimitive from "@rn-primitives/checkbox";
|
|
16
|
+
import { useItemControlLabel } from "./Item.js";
|
|
12
17
|
const DEFAULT_HIT_SLOP = 8;
|
|
13
18
|
const SIZE_CONFIGS = {
|
|
14
19
|
sm: { size: 16, iconSize: 12, strokeWidth: 2.5 },
|
|
@@ -20,38 +25,20 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
|
|
|
20
25
|
const reduceMotion = useReducedMotion();
|
|
21
26
|
const sizeConfig = SIZE_CONFIGS[size];
|
|
22
27
|
const focusRingStyle = getFocusRingStyle();
|
|
23
|
-
const [focused, setFocused] = useState(false);
|
|
24
28
|
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
25
29
|
disabled: !!disabled,
|
|
26
30
|
scaleTo: 0.92,
|
|
27
31
|
haptic: false,
|
|
28
32
|
});
|
|
29
|
-
const showFocusRing = (
|
|
30
|
-
let ringVisible = true;
|
|
31
|
-
if (Platform.OS === "web") {
|
|
32
|
-
const target = event?.nativeEvent?.target;
|
|
33
|
-
if (target && typeof target.matches === "function") {
|
|
34
|
-
try {
|
|
35
|
-
ringVisible = target.matches(":focus-visible");
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
ringVisible = true;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
setFocused(ringVisible);
|
|
43
|
-
};
|
|
44
|
-
const hideFocusRing = () => {
|
|
45
|
-
setFocused(false);
|
|
46
|
-
};
|
|
33
|
+
const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible();
|
|
47
34
|
// Simple fast opacity for the checkmark icon
|
|
48
|
-
const checkOpacity =
|
|
35
|
+
const checkOpacity = useAnimatedValue(checked || indeterminate ? 1 : 0);
|
|
49
36
|
const isVisuallyChecked = !!checked || indeterminate;
|
|
50
37
|
const animateCheckOpacity = useCallback((nextVisible) => {
|
|
51
38
|
Animated.timing(checkOpacity, {
|
|
52
39
|
toValue: nextVisible ? 1 : 0,
|
|
53
40
|
duration: reduceMotion ? 0 : 60,
|
|
54
|
-
useNativeDriver:
|
|
41
|
+
useNativeDriver: shouldUseNativeDriver,
|
|
55
42
|
}).start();
|
|
56
43
|
}, [checkOpacity, reduceMotion]);
|
|
57
44
|
useEffect(() => {
|
|
@@ -59,7 +46,7 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
|
|
|
59
46
|
}, [animateCheckOpacity, isVisuallyChecked]);
|
|
60
47
|
const wrappedOnCheckedChange = (next) => {
|
|
61
48
|
if (next)
|
|
62
|
-
|
|
49
|
+
hapticSelection();
|
|
63
50
|
animateCheckOpacity(next);
|
|
64
51
|
onCheckedChange?.(next);
|
|
65
52
|
};
|
|
@@ -71,39 +58,59 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
|
|
|
71
58
|
: getContrastingColor(theme.colors.background, theme.colors.text, theme.colors.textDim);
|
|
72
59
|
// Flatten style override for web compatibility
|
|
73
60
|
const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
61
|
+
// The primitive Root is the ONE control (role, state, name); the visual box
|
|
62
|
+
// is a child so the control can be larger than what it draws. On web, where
|
|
63
|
+
// `hitSlop` does nothing, a 16/20px box is below the 24px WCAG 2.5.8 minimum,
|
|
64
|
+
// so the control grows to `spacing.minTarget` and takes the extra back in
|
|
65
|
+
// negative margins — the row's layout does not move.
|
|
66
|
+
const hitSize = Platform.OS === "web" ? Math.max(sizeConfig.size, spacing.minTarget) : sizeConfig.size;
|
|
67
|
+
const hitOverflow = (hitSize - sizeConfig.size) / 2;
|
|
68
|
+
// The label text names the control on every platform (react-native-web
|
|
69
|
+
// maps `accessibilityLabel` to `aria-label`); no `useId` link, because ids
|
|
70
|
+
// do not survive Expo Router's streamed hydration (see hooks/useHydrated).
|
|
71
|
+
const rowLabel = useItemControlLabel({ ...props, accessibilityLabel: label });
|
|
72
|
+
const labelLink = label !== undefined ? { accessibilityLabel: label } : rowLabel;
|
|
73
|
+
const checkboxElement = (_jsx(Animated.View, { style: scaleStyle, children: _jsx(CheckboxPrimitive.Root, { ...labelLink, ...props, checked: checked, onCheckedChange: wrappedOnCheckedChange, disabled: disabled, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
|
|
74
|
+
...styles.control,
|
|
75
|
+
minWidth: hitSize,
|
|
76
|
+
minHeight: hitSize,
|
|
77
|
+
...(hitOverflow > 0 && { margin: -hitOverflow }),
|
|
81
78
|
...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
|
|
82
|
-
...(focused && !disabled ? focusRingStyle : null),
|
|
83
|
-
...(flattenedStyle || {}),
|
|
84
79
|
}, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "checkbox", accessibilityState: {
|
|
85
80
|
checked: indeterminate ? "mixed" : checked,
|
|
86
81
|
disabled: !!disabled,
|
|
87
|
-
},
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
82
|
+
}, children: _jsx(View, { style: {
|
|
83
|
+
...styles.box,
|
|
84
|
+
borderColor,
|
|
85
|
+
backgroundColor: isVisuallyChecked ? theme.colors.primary : theme.colors.background,
|
|
86
|
+
width: sizeConfig.size,
|
|
87
|
+
height: sizeConfig.size,
|
|
88
|
+
opacity: disabled ? 0.5 : 1,
|
|
89
|
+
...(focused && !disabled ? focusRingStyle : null),
|
|
90
|
+
...(flattenedStyle || {}),
|
|
91
|
+
}, children: _jsx(CheckboxPrimitive.Indicator, { style: {
|
|
92
|
+
justifyContent: "center",
|
|
93
|
+
alignItems: "center",
|
|
94
|
+
}, children: _jsx(Animated.View, { style: { opacity: checkOpacity }, children: indeterminate ? (_jsx(Icon, { name: "minus", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) : (_jsx(Icon, { name: "check", size: sizeConfig.iconSize, color: theme.colors.primaryForeground })) }) }) }) }) }));
|
|
91
95
|
// If no label, return just the checkbox
|
|
92
96
|
if (!label) {
|
|
93
97
|
return checkboxElement;
|
|
94
98
|
}
|
|
95
|
-
// With label,
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}, accessibilityLabel: label, children: [checkboxElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: [
|
|
99
|
+
// With a label, a non-accessible wrapper extends the tap area to the text.
|
|
100
|
+
// It carries no role or state: the primitive Root above is the only control
|
|
101
|
+
// a screen reader meets, and it carries the label text as its name.
|
|
102
|
+
return (_jsxs(Pressable, { onPress: () => !disabled && wrappedOnCheckedChange(!checked), style: [styles.container, labelStyle], disabled: disabled, accessible: false, focusable: false, children: [checkboxElement, _jsx(View, { style: styles.labelContainer, children: _jsxs(StyledText, { selectable: false, style: [
|
|
100
103
|
styles.label,
|
|
101
104
|
{ color: theme.colors.text },
|
|
102
105
|
disabled && styles.disabledLabel,
|
|
103
106
|
error && { color: theme.colors.destructive },
|
|
104
107
|
], children: [label, required && (_jsx(StyledText, { selectable: false, style: [styles.required, { color: theme.colors.destructive }], children: " *" }))] }) })] }));
|
|
105
108
|
}
|
|
106
|
-
const styles = StyleSheet.create({
|
|
109
|
+
const styles = /*#__PURE__*/ StyleSheet.create({
|
|
110
|
+
control: {
|
|
111
|
+
justifyContent: "center",
|
|
112
|
+
alignItems: "center",
|
|
113
|
+
},
|
|
107
114
|
box: {
|
|
108
115
|
// radiusXs (not radiusSm) — at the checkbox's 16-24px sizes, radiusSm
|
|
109
116
|
// post-rebase (8px) reads as over-rounded; radiusXs keeps the same
|
|
@@ -126,7 +133,7 @@ const styles = StyleSheet.create({
|
|
|
126
133
|
fontSize: 14,
|
|
127
134
|
},
|
|
128
135
|
disabledLabel: {
|
|
129
|
-
opacity:
|
|
136
|
+
opacity: interaction.disabledOpacity,
|
|
130
137
|
},
|
|
131
138
|
required: {
|
|
132
139
|
fontWeight: "bold",
|
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Animated, Platform, StyleSheet, View } from "react-native";
|
|
3
|
-
import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
|
|
3
|
+
import { TextClassContext, TextSelectabilityContext } from "./StyledText.context.js";
|
|
4
4
|
import { spacing } from "../constants/spacing.js";
|
|
5
5
|
import { useTheme } from "../hooks/useTheme.js";
|
|
6
6
|
import { useScalePress } from "../hooks/useScalePress.js";
|
|
7
|
+
import { useFocusVisible } from "../hooks/useFocusVisible.js";
|
|
7
8
|
import * as CollapsiblePrimitive from "@rn-primitives/collapsible";
|
|
8
9
|
function Collapsible({ children, ...props }) {
|
|
9
10
|
return (_jsx(CollapsiblePrimitive.Root, { ...props, asChild: Platform.OS !== "web", children: _jsx(View, { children: children }) }));
|
|
10
11
|
}
|
|
11
12
|
function CollapsibleTrigger({ style: styleOverride, disabled, ...props }) {
|
|
12
|
-
const { theme } = useTheme();
|
|
13
|
+
const { theme, getFocusRingStyle } = useTheme();
|
|
13
14
|
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
14
15
|
disabled: !!disabled,
|
|
15
16
|
scaleTo: 0.97,
|
|
16
17
|
haptic: false,
|
|
17
18
|
});
|
|
18
|
-
|
|
19
|
+
const focus = useFocusVisible();
|
|
20
|
+
return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(Animated.View, { style: scaleStyle, children: _jsx(CollapsiblePrimitive.Trigger, { disabled: disabled, ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: focus.onFocus, onBlur: focus.onBlur, style: {
|
|
19
21
|
flexDirection: "row",
|
|
20
22
|
alignItems: "center",
|
|
21
23
|
justifyContent: "space-between",
|
|
22
24
|
paddingVertical: spacing.sm,
|
|
25
|
+
borderRadius: spacing.radiusSm,
|
|
23
26
|
...(Platform.OS === "web" && {
|
|
24
27
|
cursor: "pointer",
|
|
25
28
|
outlineStyle: "none",
|
|
26
29
|
userSelect: "none",
|
|
27
30
|
}),
|
|
31
|
+
...(focus.focused && !disabled ? getFocusRingStyle() : null),
|
|
28
32
|
...(styleOverride && typeof styleOverride !== "function"
|
|
29
33
|
? StyleSheet.flatten(styleOverride)
|
|
30
34
|
: {}),
|