@mrmeg/expo-ui 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +712 -0
- package/LLM_USAGE.md +260 -32
- package/README.md +558 -101
- 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 +66 -9
- package/dist/components/BottomSheet.js +143 -39
- package/dist/components/Button.d.ts +20 -4
- package/dist/components/Button.js +64 -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 +178 -29
- 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 +7 -3
- 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 +74 -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 +23 -32
- 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 +73 -27
- 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/bottomSheetHostSupport.d.ts +46 -0
- package/dist/components/bottomSheetHostSupport.js +67 -0
- package/dist/components/index.d.ts +46 -44
- package/dist/components/index.js +2 -0
- package/dist/components/keyboardDismiss.d.ts +25 -0
- package/dist/components/keyboardDismiss.js +60 -3
- 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 -4
- package/dist/lib/index.js +2 -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/stateSurface.d.ts +28 -0
- package/dist/lib/stateSurface.js +30 -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,16 +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";
|
|
16
|
+
import { stateSurfaceProps } from "../lib/stateSurface.js";
|
|
17
|
+
import { composeRefs } from "../lib/composeRefs.js";
|
|
18
|
+
import { getWebHitInsets, verticalSlopFor, WebHitTarget } from "../lib/webHitTarget.js";
|
|
14
19
|
const SIZE_CONFIGS = {
|
|
15
20
|
sm: {
|
|
16
21
|
paddingVertical: spacing.xxs,
|
|
@@ -31,7 +36,9 @@ const SIZE_CONFIGS = {
|
|
|
31
36
|
height: 40,
|
|
32
37
|
},
|
|
33
38
|
};
|
|
34
|
-
|
|
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);
|
|
35
42
|
/**
|
|
36
43
|
* Enhanced Button Component
|
|
37
44
|
*
|
|
@@ -72,7 +79,7 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
|
|
|
72
79
|
* ```
|
|
73
80
|
*/
|
|
74
81
|
function ButtonRoot(props) {
|
|
75
|
-
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;
|
|
76
83
|
const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
|
|
77
84
|
const styles = themedStyles(theme)[size];
|
|
78
85
|
const shadowStyle = getShadowStyle("subtle");
|
|
@@ -128,38 +135,39 @@ function ButtonRoot(props) {
|
|
|
128
135
|
: preset === "secondary"
|
|
129
136
|
? theme.colors.secondaryForeground
|
|
130
137
|
: getContrastingColor(backgroundColor, palette.white, palette.black);
|
|
131
|
-
const [focused, setFocused] = useState(false);
|
|
132
138
|
const [restingWidth, setRestingWidth] = useState();
|
|
133
|
-
|
|
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;
|
|
134
148
|
const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
|
|
135
149
|
disabled: !!isDisabled,
|
|
136
|
-
haptic:
|
|
137
|
-
scaleTo: preset === "link" ? 1 :
|
|
150
|
+
haptic: haptic ?? "setting",
|
|
151
|
+
scaleTo: preset === "link" ? 1 : interaction.pressedScale,
|
|
138
152
|
});
|
|
139
|
-
const showFocusRing = (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (Platform.OS
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
onFocus?.(event);
|
|
158
|
-
};
|
|
159
|
-
const hideFocusRing = (event) => {
|
|
160
|
-
setFocused(false);
|
|
161
|
-
onBlur?.(event);
|
|
162
|
-
};
|
|
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]);
|
|
163
171
|
const handlePressIn = (event) => {
|
|
164
172
|
pressHandlers.onPressIn();
|
|
165
173
|
onPressIn?.(event);
|
|
@@ -168,6 +176,12 @@ function ButtonRoot(props) {
|
|
|
168
176
|
pressHandlers.onPressOut();
|
|
169
177
|
onPressOut?.(event);
|
|
170
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;
|
|
171
185
|
const handleButtonLayout = useCallback((event) => {
|
|
172
186
|
if (loading || fullWidth)
|
|
173
187
|
return;
|
|
@@ -181,7 +195,13 @@ function ButtonRoot(props) {
|
|
|
181
195
|
return nextWidth;
|
|
182
196
|
});
|
|
183
197
|
}, [fullWidth, loading]);
|
|
184
|
-
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
|
|
202
|
+
// Android: fixed native tree across disabled/pressed/loading
|
|
203
|
+
// flips (see `stateSurfaceProps`).
|
|
204
|
+
, { ...stateSurfaceProps(), style: [
|
|
185
205
|
styles.button,
|
|
186
206
|
// Host-app radius override (see `setShape`). Sits right
|
|
187
207
|
// after the static radius so presets, state styles, and —
|
|
@@ -201,11 +221,12 @@ function ButtonRoot(props) {
|
|
|
201
221
|
state.pressed && pressedStyleOverride,
|
|
202
222
|
isDisabled && styles.disabled,
|
|
203
223
|
isDisabled && disabledStyleOverride,
|
|
204
|
-
|
|
224
|
+
// An announce-only disabled button is still focusable: show its ring.
|
|
225
|
+
focused && !pressBlocked && focusRingStyle,
|
|
205
226
|
loading && restingWidth !== undefined && !fullWidth && { width: restingWidth },
|
|
206
227
|
// Spread array styles from Slot to prevent nested arrays on web
|
|
207
228
|
...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
|
|
208
|
-
], onLayout: handleButtonLayout, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { 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: [
|
|
209
230
|
labelTextStyle,
|
|
210
231
|
state.pressed && styles.pressedText,
|
|
211
232
|
state.pressed && pressedTextStyleOverride,
|
|
@@ -215,7 +236,7 @@ function ButtonRoot(props) {
|
|
|
215
236
|
// Children render as-is. For text content, use the explicit
|
|
216
237
|
// <Button.Text> subcomponent so it inherits control typography
|
|
217
238
|
// via context instead of relying on a runtime `typeof` check.
|
|
218
|
-
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" })] }) })) }) }) }) }));
|
|
219
240
|
}
|
|
220
241
|
/**
|
|
221
242
|
* Button.Text
|
|
@@ -283,7 +304,7 @@ const createStyles = (theme, size) => {
|
|
|
283
304
|
},
|
|
284
305
|
buttonOutline: {
|
|
285
306
|
backgroundColor: "transparent",
|
|
286
|
-
borderWidth:
|
|
307
|
+
borderWidth: OUTLINE_BORDER_WIDTH,
|
|
287
308
|
borderColor: theme.colors.input,
|
|
288
309
|
},
|
|
289
310
|
buttonGhost: {
|
|
@@ -309,16 +330,16 @@ const createStyles = (theme, size) => {
|
|
|
309
330
|
userSelect: "none",
|
|
310
331
|
},
|
|
311
332
|
pressed: {
|
|
312
|
-
opacity:
|
|
333
|
+
opacity: interaction.pressedOpacity,
|
|
313
334
|
},
|
|
314
335
|
pressedMuted: {
|
|
315
336
|
backgroundColor: theme.colors.muted,
|
|
316
337
|
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
},
|
|
338
|
+
// The container's pressed opacity already dims the label; a second layer
|
|
339
|
+
// here would compound it.
|
|
340
|
+
pressedText: {},
|
|
320
341
|
disabled: {
|
|
321
|
-
opacity:
|
|
342
|
+
opacity: interaction.disabledOpacity,
|
|
322
343
|
},
|
|
323
344
|
leftAccessory: {
|
|
324
345
|
marginRight: spacing.sm,
|
|
@@ -333,7 +354,7 @@ const createStyles = (theme, size) => {
|
|
|
333
354
|
},
|
|
334
355
|
});
|
|
335
356
|
};
|
|
336
|
-
const themedStyles = createThemedStyles((theme) => ({
|
|
357
|
+
const themedStyles = /*#__PURE__*/ createThemedStyles((theme) => ({
|
|
337
358
|
sm: createStyles(theme, "sm"),
|
|
338
359
|
md: createStyles(theme, "md"),
|
|
339
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
|
: {}),
|