@mrmeg/expo-ui 0.27.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +598 -0
  2. package/LLM_USAGE.md +233 -35
  3. package/README.md +504 -103
  4. package/dist/components/Accordion.js +12 -6
  5. package/dist/components/AnimatedView.d.ts +1 -1
  6. package/dist/components/AnimatedView.js +5 -2
  7. package/dist/components/Avatar.d.ts +1 -1
  8. package/dist/components/Avatar.js +2 -2
  9. package/dist/components/Badge.js +5 -1
  10. package/dist/components/BottomSheet.d.ts +29 -1
  11. package/dist/components/BottomSheet.js +107 -30
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +60 -43
  14. package/dist/components/Card.d.ts +1 -1
  15. package/dist/components/Card.js +24 -4
  16. package/dist/components/Carousel.js +2 -2
  17. package/dist/components/Checkbox.js +51 -44
  18. package/dist/components/Collapsible.js +7 -3
  19. package/dist/components/Dialog.js +87 -16
  20. package/dist/components/Drawer.d.ts +1 -1
  21. package/dist/components/Drawer.js +65 -55
  22. package/dist/components/DropdownMenu.js +11 -7
  23. package/dist/components/EmptyState.d.ts +2 -2
  24. package/dist/components/EmptyState.js +5 -2
  25. package/dist/components/Hydrated.d.ts +31 -0
  26. package/dist/components/Hydrated.js +30 -0
  27. package/dist/components/Icon.d.ts +17 -5
  28. package/dist/components/Icon.js +31 -10
  29. package/dist/components/InputOTP.js +5 -2
  30. package/dist/components/Item.d.ts +103 -4
  31. package/dist/components/Item.js +168 -15
  32. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  33. package/dist/components/KeyboardAvoidingView.js +4 -1
  34. package/dist/components/Label.js +3 -2
  35. package/dist/components/MaxWidthContainer.js +1 -1
  36. package/dist/components/Notification.js +45 -27
  37. package/dist/components/Popover.d.ts +18 -5
  38. package/dist/components/Popover.js +72 -8
  39. package/dist/components/Progress.js +8 -6
  40. package/dist/components/RadioGroup.js +52 -49
  41. package/dist/components/Screen.d.ts +61 -0
  42. package/dist/components/Screen.js +62 -0
  43. package/dist/components/SectionHeader.d.ts +1 -1
  44. package/dist/components/SegmentedControl.d.ts +12 -4
  45. package/dist/components/SegmentedControl.js +94 -4
  46. package/dist/components/Select.js +21 -26
  47. package/dist/components/Skeleton.js +24 -10
  48. package/dist/components/Slider.d.ts +11 -3
  49. package/dist/components/Slider.js +73 -6
  50. package/dist/components/StatCard.d.ts +4 -2
  51. package/dist/components/StatCard.js +3 -1
  52. package/dist/components/StyledText.d.ts +7 -1
  53. package/dist/components/StyledText.js +5 -3
  54. package/dist/components/Switch.d.ts +1 -1
  55. package/dist/components/Switch.js +20 -30
  56. package/dist/components/Tabs.d.ts +2 -2
  57. package/dist/components/Tabs.js +13 -24
  58. package/dist/components/TextInput.d.ts +1 -1
  59. package/dist/components/TextInput.js +71 -26
  60. package/dist/components/Toggle.d.ts +2 -42
  61. package/dist/components/Toggle.js +38 -27
  62. package/dist/components/ToggleGroup.d.ts +1 -1
  63. package/dist/components/ToggleGroup.js +37 -32
  64. package/dist/components/Tooltip.d.ts +1 -1
  65. package/dist/components/Tooltip.js +9 -3
  66. package/dist/components/UIProvider.d.ts +22 -2
  67. package/dist/components/UIProvider.js +15 -3
  68. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  69. package/dist/components/bottomSheetDismiss.js +44 -0
  70. package/dist/components/index.d.ts +46 -44
  71. package/dist/components/index.js +2 -0
  72. package/dist/components/keyboardDismiss.js +12 -4
  73. package/dist/components/nativeTextField.android.js +13 -9
  74. package/dist/constants/colors.d.ts +40 -5
  75. package/dist/constants/colors.js +26 -8
  76. package/dist/constants/fonts.d.ts +41 -2
  77. package/dist/constants/fonts.js +46 -7
  78. package/dist/constants/index.d.ts +5 -4
  79. package/dist/constants/index.js +1 -0
  80. package/dist/constants/interaction.d.ts +18 -0
  81. package/dist/constants/interaction.js +18 -0
  82. package/dist/constants/spacing.d.ts +2 -1
  83. package/dist/constants/spacing.js +2 -1
  84. package/dist/design-system.json +154 -2
  85. package/dist/hooks/index.d.ts +12 -7
  86. package/dist/hooks/index.js +5 -0
  87. package/dist/hooks/useDimensions.d.ts +13 -8
  88. package/dist/hooks/useDimensions.js +123 -48
  89. package/dist/hooks/useFocusVisible.d.ts +30 -0
  90. package/dist/hooks/useFocusVisible.js +45 -0
  91. package/dist/hooks/useFontStyle.d.ts +2 -2
  92. package/dist/hooks/useFontStyle.js +3 -2
  93. package/dist/hooks/useHydrated.d.ts +13 -0
  94. package/dist/hooks/useHydrated.js +19 -0
  95. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  96. package/dist/hooks/useNotificationOffset.js +34 -0
  97. package/dist/hooks/useResources.d.ts +21 -20
  98. package/dist/hooks/useResources.js +72 -68
  99. package/dist/hooks/useScalePress.d.ts +8 -5
  100. package/dist/hooks/useScalePress.js +16 -9
  101. package/dist/hooks/useShape.d.ts +24 -0
  102. package/dist/hooks/useShape.js +24 -0
  103. package/dist/hooks/useStaggeredEntrance.js +9 -7
  104. package/dist/hooks/useTheme.d.ts +3 -3
  105. package/dist/hooks/useTheme.js +177 -66
  106. package/dist/hooks/useWindowInsets.d.ts +13 -0
  107. package/dist/hooks/useWindowInsets.js +23 -0
  108. package/dist/index.d.ts +5 -5
  109. package/dist/lib/composeRefs.d.ts +8 -0
  110. package/dist/lib/composeRefs.js +20 -0
  111. package/dist/lib/haptics.d.ts +12 -0
  112. package/dist/lib/haptics.js +21 -0
  113. package/dist/lib/index.d.ts +6 -5
  114. package/dist/lib/index.js +1 -0
  115. package/dist/lib/interFonts.d.ts +15 -0
  116. package/dist/lib/interFonts.js +1 -0
  117. package/dist/lib/interFonts.native.d.ts +3 -0
  118. package/dist/lib/interFonts.native.js +20 -0
  119. package/dist/lib/popoverPlacement.d.ts +36 -0
  120. package/dist/lib/popoverPlacement.js +29 -0
  121. package/dist/lib/themedStyles.d.ts +1 -1
  122. package/dist/lib/useAnimatedValue.d.ts +15 -0
  123. package/dist/lib/useAnimatedValue.js +19 -0
  124. package/dist/lib/webHitTarget.d.ts +40 -0
  125. package/dist/lib/webHitTarget.js +37 -0
  126. package/dist/state/feedbackStore.d.ts +24 -0
  127. package/dist/state/feedbackStore.js +16 -0
  128. package/dist/state/globalUIStore.d.ts +27 -0
  129. package/dist/state/globalUIStore.js +23 -0
  130. package/dist/state/index.d.ts +7 -5
  131. package/dist/state/index.js +2 -0
  132. package/dist/state/initialScheme.d.ts +27 -0
  133. package/dist/state/initialScheme.js +29 -0
  134. package/dist/state/notify.d.ts +1 -1
  135. package/dist/state/themeColorScope.d.ts +1 -1
  136. package/dist/state/themeStore.d.ts +69 -16
  137. package/dist/state/themeStore.js +65 -38
  138. package/llms-full.md +56 -8
  139. package/llms.txt +10 -1
  140. package/package.json +69 -2
@@ -1,17 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { use, useCallback, useMemo, useState } from "react";
2
+ import { use, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { Pressable, StyleSheet, View, Platform, ActivityIndicator, Animated, } from "react-native";
4
4
  import { spacing } from "../constants/spacing.js";
5
+ import { interaction } from "../constants/interaction.js";
5
6
  import { StyledText } from "./StyledText.js";
6
7
  import { Icon } from "./Icon.js";
7
- import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
8
+ import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context.js";
8
9
  import { palette } from "../constants/colors.js";
9
10
  import { useTheme } from "../hooks/useTheme.js";
11
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
10
12
  import { useFontStyle } from "../hooks/useFontStyle.js";
11
13
  import { useScalePress } from "../hooks/useScalePress.js";
12
14
  import { useThemeStore } from "../state/themeStore.js";
13
15
  import { createThemedStyles } from "../lib/themedStyles.js";
14
16
  import { stateSurfaceProps } from "../lib/stateSurface.js";
17
+ import { composeRefs } from "../lib/composeRefs.js";
18
+ import { getWebHitInsets, verticalSlopFor, WebHitTarget } from "../lib/webHitTarget.js";
15
19
  const SIZE_CONFIGS = {
16
20
  sm: {
17
21
  paddingVertical: spacing.xxs,
@@ -32,7 +36,9 @@ const SIZE_CONFIGS = {
32
36
  height: 40,
33
37
  },
34
38
  };
35
- const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarget - sizeConfig.height) / 2);
39
+ /** `outline` border. The web hit extender adds it back (see `WebHitTarget`). */
40
+ const OUTLINE_BORDER_WIDTH = 1;
41
+ const getNativeHitSlop = (sizeConfig) => verticalSlopFor(sizeConfig.height);
36
42
  /**
37
43
  * Enhanced Button Component
38
44
  *
@@ -73,7 +79,7 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
73
79
  * ```
74
80
  */
75
81
  function ButtonRoot(props) {
76
- const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow: withShadowProp, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, ...rest } = props;
82
+ const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow: withShadowProp, haptic, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, accessibilityState, "aria-disabled": ariaDisabled, ref: forwardedRef, ...rest } = props;
77
83
  const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
78
84
  const styles = themedStyles(theme)[size];
79
85
  const shadowStyle = getShadowStyle("subtle");
@@ -129,38 +135,39 @@ function ButtonRoot(props) {
129
135
  : preset === "secondary"
130
136
  ? theme.colors.secondaryForeground
131
137
  : getContrastingColor(backgroundColor, palette.white, palette.black);
132
- const [focused, setFocused] = useState(false);
133
138
  const [restingWidth, setRestingWidth] = useState();
134
- const isDisabled = disabled || loading;
139
+ // Two kinds of disabled. `pressBlocked` (the `disabled` prop, or loading)
140
+ // swallows presses. `isDisabled` is what is ANNOUNCED and drawn: that, or a
141
+ // consumer `aria-disabled` / `accessibilityState.disabled`, which keeps the
142
+ // button focusable and pressable so the press can explain itself.
143
+ const pressBlocked = !!disabled || loading;
144
+ // Web only: see `lib/webHitTarget`. Native keeps `hitSlop` on the Pressable.
145
+ const webHitInsets = Platform.OS === "web" ? getWebHitInsets(rest.hitSlop, sizeConfig.height) : null;
146
+ const hitBorder = preset === "outline" ? OUTLINE_BORDER_WIDTH : 0;
147
+ const isDisabled = pressBlocked || (ariaDisabled ?? accessibilityState?.disabled) === true;
135
148
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
136
149
  disabled: !!isDisabled,
137
- haptic: false,
138
- scaleTo: preset === "link" ? 1 : 0.97,
150
+ haptic: haptic ?? "setting",
151
+ scaleTo: preset === "link" ? 1 : interaction.pressedScale,
139
152
  });
140
- const showFocusRing = (event) => {
141
- // On web, pointer taps focus the element too, which left the ring visible
142
- // after every click. :focus-visible is only true for keyboard-driven
143
- // focus, so gate the ring on it; fall back to showing the ring when the
144
- // target can't be queried (non-DOM targets, older engines).
145
- let ringVisible = true;
146
- if (Platform.OS === "web") {
147
- const target = event?.nativeEvent?.target;
148
- if (target && typeof target.matches === "function") {
149
- try {
150
- ringVisible = target.matches(":focus-visible");
151
- }
152
- catch {
153
- ringVisible = true;
154
- }
155
- }
156
- }
157
- setFocused(ringVisible);
158
- onFocus?.(event);
159
- };
160
- const hideFocusRing = (event) => {
161
- setFocused(false);
162
- onBlur?.(event);
163
- };
153
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible({ onFocus, onBlur });
154
+ const hostRef = useRef(null);
155
+ const pressBlockedRef = useRef(pressBlocked);
156
+ pressBlockedRef.current = pressBlocked;
157
+ const announceOnly = isDisabled && !pressBlocked;
158
+ useLayoutEffect(() => {
159
+ if (Platform.OS !== "web" || !announceOnly)
160
+ return;
161
+ const node = hostRef.current;
162
+ if (!node?.setAttribute)
163
+ return;
164
+ node.setAttribute("aria-disabled", "true");
165
+ return () => {
166
+ if (!pressBlockedRef.current)
167
+ node.removeAttribute?.("aria-disabled");
168
+ };
169
+ }, [announceOnly]);
170
+ const composedRef = useMemo(() => composeRefs(hostRef, forwardedRef), [forwardedRef]);
164
171
  const handlePressIn = (event) => {
165
172
  pressHandlers.onPressIn();
166
173
  onPressIn?.(event);
@@ -169,6 +176,12 @@ function ButtonRoot(props) {
169
176
  pressHandlers.onPressOut();
170
177
  onPressOut?.(event);
171
178
  };
179
+ // The resting width is only read while `loading`, to keep the button from
180
+ // resizing when its label changes under the spinner (the label stays
181
+ // mounted at opacity 0, so an unchanged label already keeps the width). A
182
+ // button that is never given a `loading` prop can't enter that state, so it
183
+ // skips the layout callback and the extra render it caused on every mount.
184
+ const measuresRestingWidth = props.loading !== undefined && !fullWidth;
172
185
  const handleButtonLayout = useCallback((event) => {
173
186
  if (loading || fullWidth)
174
187
  return;
@@ -182,7 +195,10 @@ function ButtonRoot(props) {
182
195
  return nextWidth;
183
196
  });
184
197
  }, [fullWidth, loading]);
185
- return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: labelTextStyle, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityState: { disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View
198
+ return (_jsx(TextColorContext.Provider, { value: textColor, children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(TextStyleContext.Provider, { value: labelTextStyle, children: _jsx(Pressable, { ref: composedRef, accessibilityRole: "button", accessibilityState: { ...accessibilityState, disabled: !!isDisabled, busy: loading }, ...rest, onPressIn: handlePressIn, onPressOut: handlePressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: { alignSelf: fullWidth ? "stretch" : flattenedStyle?.alignSelf ?? "flex-start" }, hitSlop: rest.hitSlop ?? (Platform.OS === "web" ? undefined : getNativeHitSlop(sizeConfig)),
199
+ // Never `false`: RN's Pressable would overwrite a consumer
200
+ // `accessibilityState.disabled` with it.
201
+ disabled: pressBlocked || undefined, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View
186
202
  // Android: fixed native tree across disabled/pressed/loading
187
203
  // flips (see `stateSurfaceProps`).
188
204
  , { ...stateSurfaceProps(), style: [
@@ -205,11 +221,12 @@ function ButtonRoot(props) {
205
221
  state.pressed && pressedStyleOverride,
206
222
  isDisabled && styles.disabled,
207
223
  isDisabled && disabledStyleOverride,
208
- focused && !isDisabled && focusRingStyle,
224
+ // An announce-only disabled button is still focusable: show its ring.
225
+ focused && !pressBlocked && focusRingStyle,
209
226
  loading && restingWidth !== undefined && !fullWidth && { width: restingWidth },
210
227
  // Spread array styles from Slot to prevent nested arrays on web
211
228
  ...(Array.isArray(styleOverride) ? styleOverride : [styleOverride]),
212
- ], onLayout: handleButtonLayout, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { ...stateSurfaceProps(), style: [styles.content, loading && styles.loadingContent, { pointerEvents: loading ? "none" : "auto" }], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
229
+ ], onLayout: measuresRestingWidth ? handleButtonLayout : undefined, children: [loading && (_jsx(View, { style: [styles.loaderOverlay, { pointerEvents: "none" }], children: _jsx(ActivityIndicator, { size: "small", color: textColor }) })), _jsxs(View, { ...stateSurfaceProps(), style: [styles.content, loading && styles.loadingContent, { pointerEvents: loading ? "none" : "auto" }], children: [!!LeftAccessory && (_jsx(LeftAccessory, { style: styles.leftAccessory, pressableState: state, disabled: isDisabled })), (tx || text) ? (_jsx(StyledText, { tx: tx, text: text, txOptions: txOptions, style: [
213
230
  labelTextStyle,
214
231
  state.pressed && styles.pressedText,
215
232
  state.pressed && pressedTextStyleOverride,
@@ -219,7 +236,7 @@ function ButtonRoot(props) {
219
236
  // Children render as-is. For text content, use the explicit
220
237
  // <Button.Text> subcomponent so it inherits control typography
221
238
  // via context instead of relying on a runtime `typeof` check.
222
- children ?? null), !!RightAccessory && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] })] }) })) }) }) }) }));
239
+ children ?? null), !!RightAccessory && (_jsx(RightAccessory, { style: styles.rightAccessory, pressableState: state, disabled: isDisabled }))] }), _jsx(WebHitTarget, { insets: webHitInsets, border: hitBorder, testID: "button-hit-target" })] }) })) }) }) }) }));
223
240
  }
224
241
  /**
225
242
  * Button.Text
@@ -287,7 +304,7 @@ const createStyles = (theme, size) => {
287
304
  },
288
305
  buttonOutline: {
289
306
  backgroundColor: "transparent",
290
- borderWidth: 1,
307
+ borderWidth: OUTLINE_BORDER_WIDTH,
291
308
  borderColor: theme.colors.input,
292
309
  },
293
310
  buttonGhost: {
@@ -313,16 +330,16 @@ const createStyles = (theme, size) => {
313
330
  userSelect: "none",
314
331
  },
315
332
  pressed: {
316
- opacity: 0.9,
333
+ opacity: interaction.pressedOpacity,
317
334
  },
318
335
  pressedMuted: {
319
336
  backgroundColor: theme.colors.muted,
320
337
  },
321
- pressedText: {
322
- opacity: 0.9,
323
- },
338
+ // The container's pressed opacity already dims the label; a second layer
339
+ // here would compound it.
340
+ pressedText: {},
324
341
  disabled: {
325
- opacity: 0.6,
342
+ opacity: interaction.disabledOpacity,
326
343
  },
327
344
  leftAccessory: {
328
345
  marginRight: spacing.sm,
@@ -337,7 +354,7 @@ const createStyles = (theme, size) => {
337
354
  },
338
355
  });
339
356
  };
340
- const themedStyles = createThemedStyles((theme) => ({
357
+ const themedStyles = /*#__PURE__*/ createThemedStyles((theme) => ({
341
358
  sm: createStyles(theme, "sm"),
342
359
  md: createStyles(theme, "md"),
343
360
  lg: createStyles(theme, "lg"),
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { ViewStyle, TextStyle, StyleProp } from "react-native";
3
- import { TextProps } from "./StyledText";
3
+ import { TextProps } from "./StyledText.js";
4
4
  export interface CardProps {
5
5
  /** Card contents */
6
6
  children?: React.ReactNode;
@@ -3,8 +3,11 @@ import { createContext, use } from "react";
3
3
  import { View, Pressable, StyleSheet, Platform, Animated } from "react-native";
4
4
  import { StyledText } from "./StyledText.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
+ import { useShape } from "../hooks/useShape.js";
6
7
  import { useScalePress } from "../hooks/useScalePress.js";
8
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
7
9
  import { spacing } from "../constants/spacing.js";
10
+ import { interaction } from "../constants/interaction.js";
8
11
  import { createThemedStyles } from "../lib/themedStyles.js";
9
12
  /**
10
13
  * Card Component
@@ -12,6 +15,13 @@ import { createThemedStyles } from "../lib/themedStyles.js";
12
15
  * A themed container component with header, content, and footer sections.
13
16
  * Follows shadcn/ui patterns with consistent styling and theme integration.
14
17
  *
18
+ * Use a Card for one item in a collection (a feed entry, a grid tile, a
19
+ * carousel slide) or for a single tappable object. It is not a layout box:
20
+ * don't wrap a screen section, a form, or a group of rows in one (use
21
+ * `ItemGroup`, `SectionHeader`, or a `Separator`), and never nest Cards. The
22
+ * parts pad `spacing.cardPadding`, so a Card inside a padded box doubles the
23
+ * inset.
24
+ *
15
25
  * Usage:
16
26
  * ```tsx
17
27
  * <Card>
@@ -38,17 +48,20 @@ function useCardContext() {
38
48
  return ctx ?? { theme, styles: themedStyles(theme) };
39
49
  }
40
50
  function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
41
- const { theme, getShadowStyle } = useTheme();
51
+ const { theme, getShadowStyle, getFocusRingStyle } = useTheme();
42
52
  const styles = themedStyles(theme);
43
53
  const shadowStyle = getShadowStyle("subtle");
54
+ // Host-app `setShape({ card })`, on the surface and on the pressable ring wrapper.
55
+ const cardRadius = useShape("card")?.borderRadius ?? spacing.radiusLg;
44
56
  const ctx = { theme, styles };
45
57
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
46
58
  disabled: !onPress || !!disabled,
47
59
  scaleTo: 0.98,
48
- haptic: false,
49
60
  });
61
+ const focus = useFocusVisible();
50
62
  const cardContent = (_jsx(View, { style: [
51
63
  styles.card,
64
+ { borderRadius: cardRadius },
52
65
  variant === "default" && styles.cardDefault,
53
66
  variant === "default" && shadowStyle,
54
67
  variant === "outline" && styles.cardOutline,
@@ -56,7 +69,14 @@ function Card({ children, style: styleOverride, variant = "default", onPress, di
56
69
  styleOverride,
57
70
  ], children: children }));
58
71
  if (onPress) {
59
- return (_jsx(CardContext.Provider, { value: ctx, children: _jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityState: { disabled: !!disabled }, ...pressHandlers, style: Platform.OS === "web" ? { cursor: "pointer" } : undefined, children: _jsx(Animated.View, { style: scaleStyle, children: cardContent }) }) }));
72
+ return (_jsx(CardContext.Provider, { value: ctx, children: _jsx(Pressable, { onPress: onPress, disabled: disabled, accessibilityRole: "button", accessibilityState: { disabled: !!disabled }, ...pressHandlers, onFocus: focus.onFocus, onBlur: focus.onBlur, style: ({ pressed }) => [
73
+ // The ring is a box shadow on this wrapper, so it needs the card's
74
+ // radius; the browser outline is off because the ring replaces it.
75
+ { borderRadius: cardRadius },
76
+ Platform.OS === "web" && { cursor: "pointer", outlineStyle: "none" },
77
+ pressed && { opacity: interaction.pressedOpacity },
78
+ focus.focused && !disabled && getFocusRingStyle(),
79
+ ], children: _jsx(Animated.View, { style: scaleStyle, children: cardContent }) }) }));
60
80
  }
61
81
  return (_jsx(CardContext.Provider, { value: ctx, children: cardContent }));
62
82
  }
@@ -123,5 +143,5 @@ const createCardStyles = (theme) => StyleSheet.create({
123
143
  lineHeight: 20,
124
144
  },
125
145
  });
126
- const themedStyles = createThemedStyles(createCardStyles);
146
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createCardStyles);
127
147
  export { Card, CardHeader, CardContent, CardFooter, CardTitle, CardDescription };
@@ -197,7 +197,7 @@ const DOT_HIT_SLOP = {
197
197
  // site (it tracks the contentPadding prop) so the leading inset survives the
198
198
  // browser snapping slide 0 to the scrollport start — which also makes page `i`
199
199
  // land on `i * interval`, matching the native snap offsets.
200
- const webStyles = StyleSheet.create({
200
+ const webStyles = /*#__PURE__*/ StyleSheet.create({
201
201
  scroller: { scrollSnapType: "x mandatory" },
202
202
  item: { scrollSnapAlign: "start" },
203
203
  });
@@ -245,4 +245,4 @@ const createStyles = (theme) => StyleSheet.create({
245
245
  backgroundColor: theme.colors.border,
246
246
  },
247
247
  });
248
- const themedStyles = createThemedStyles(createStyles);
248
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createStyles);
@@ -1,14 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
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 { hapticLight } from "../lib/haptics.js";
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 = (event) => {
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 = useRef(new Animated.Value(checked || indeterminate ? 1 : 0)).current;
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: true,
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
- hapticLight();
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
- const checkboxElement = (_jsx(Animated.View, { style: scaleStyle, children: _jsx(CheckboxPrimitive.Root, { ...props, checked: checked, onCheckedChange: wrappedOnCheckedChange, disabled: disabled, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
75
- ...styles.box,
76
- borderColor,
77
- backgroundColor: isVisuallyChecked ? theme.colors.primary : theme.colors.background,
78
- width: sizeConfig.size,
79
- height: sizeConfig.size,
80
- opacity: disabled ? 0.5 : 1,
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
- }, accessibilityLabel: label, children: _jsx(CheckboxPrimitive.Indicator, { style: {
88
- justifyContent: "center",
89
- alignItems: "center",
90
- }, 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 })) }) }) }) }));
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, wrap in a container
96
- return (_jsxs(Pressable, { onPress: () => !disabled && wrappedOnCheckedChange(!checked), style: [styles.container, labelStyle], disabled: disabled, accessibilityRole: "checkbox", accessibilityState: {
97
- checked: indeterminate ? "mixed" : checked,
98
- disabled: !!disabled,
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: 0.5,
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
- 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, style: {
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
  : {}),