@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.
Files changed (145) hide show
  1. package/CHANGELOG.md +712 -0
  2. package/LLM_USAGE.md +260 -32
  3. package/README.md +558 -101
  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 +66 -9
  11. package/dist/components/BottomSheet.js +143 -39
  12. package/dist/components/Button.d.ts +20 -4
  13. package/dist/components/Button.js +64 -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 +178 -29
  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 +7 -3
  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 +74 -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 +23 -32
  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 +73 -27
  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/bottomSheetHostSupport.d.ts +46 -0
  71. package/dist/components/bottomSheetHostSupport.js +67 -0
  72. package/dist/components/index.d.ts +46 -44
  73. package/dist/components/index.js +2 -0
  74. package/dist/components/keyboardDismiss.d.ts +25 -0
  75. package/dist/components/keyboardDismiss.js +60 -3
  76. package/dist/components/nativeTextField.android.js +13 -9
  77. package/dist/constants/colors.d.ts +40 -5
  78. package/dist/constants/colors.js +26 -8
  79. package/dist/constants/fonts.d.ts +41 -2
  80. package/dist/constants/fonts.js +46 -7
  81. package/dist/constants/index.d.ts +5 -4
  82. package/dist/constants/index.js +1 -0
  83. package/dist/constants/interaction.d.ts +18 -0
  84. package/dist/constants/interaction.js +18 -0
  85. package/dist/constants/spacing.d.ts +2 -1
  86. package/dist/constants/spacing.js +2 -1
  87. package/dist/design-system.json +154 -2
  88. package/dist/hooks/index.d.ts +12 -7
  89. package/dist/hooks/index.js +5 -0
  90. package/dist/hooks/useDimensions.d.ts +13 -8
  91. package/dist/hooks/useDimensions.js +123 -48
  92. package/dist/hooks/useFocusVisible.d.ts +30 -0
  93. package/dist/hooks/useFocusVisible.js +45 -0
  94. package/dist/hooks/useFontStyle.d.ts +2 -2
  95. package/dist/hooks/useFontStyle.js +3 -2
  96. package/dist/hooks/useHydrated.d.ts +13 -0
  97. package/dist/hooks/useHydrated.js +19 -0
  98. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  99. package/dist/hooks/useNotificationOffset.js +34 -0
  100. package/dist/hooks/useResources.d.ts +21 -20
  101. package/dist/hooks/useResources.js +72 -68
  102. package/dist/hooks/useScalePress.d.ts +8 -5
  103. package/dist/hooks/useScalePress.js +16 -9
  104. package/dist/hooks/useShape.d.ts +24 -0
  105. package/dist/hooks/useShape.js +24 -0
  106. package/dist/hooks/useStaggeredEntrance.js +9 -7
  107. package/dist/hooks/useTheme.d.ts +3 -3
  108. package/dist/hooks/useTheme.js +177 -66
  109. package/dist/hooks/useWindowInsets.d.ts +13 -0
  110. package/dist/hooks/useWindowInsets.js +23 -0
  111. package/dist/index.d.ts +5 -5
  112. package/dist/lib/composeRefs.d.ts +8 -0
  113. package/dist/lib/composeRefs.js +20 -0
  114. package/dist/lib/haptics.d.ts +12 -0
  115. package/dist/lib/haptics.js +21 -0
  116. package/dist/lib/index.d.ts +6 -4
  117. package/dist/lib/index.js +2 -0
  118. package/dist/lib/interFonts.d.ts +15 -0
  119. package/dist/lib/interFonts.js +1 -0
  120. package/dist/lib/interFonts.native.d.ts +3 -0
  121. package/dist/lib/interFonts.native.js +20 -0
  122. package/dist/lib/popoverPlacement.d.ts +36 -0
  123. package/dist/lib/popoverPlacement.js +29 -0
  124. package/dist/lib/stateSurface.d.ts +28 -0
  125. package/dist/lib/stateSurface.js +30 -0
  126. package/dist/lib/themedStyles.d.ts +1 -1
  127. package/dist/lib/useAnimatedValue.d.ts +15 -0
  128. package/dist/lib/useAnimatedValue.js +19 -0
  129. package/dist/lib/webHitTarget.d.ts +40 -0
  130. package/dist/lib/webHitTarget.js +37 -0
  131. package/dist/state/feedbackStore.d.ts +24 -0
  132. package/dist/state/feedbackStore.js +16 -0
  133. package/dist/state/globalUIStore.d.ts +27 -0
  134. package/dist/state/globalUIStore.js +23 -0
  135. package/dist/state/index.d.ts +7 -5
  136. package/dist/state/index.js +2 -0
  137. package/dist/state/initialScheme.d.ts +27 -0
  138. package/dist/state/initialScheme.js +29 -0
  139. package/dist/state/notify.d.ts +1 -1
  140. package/dist/state/themeColorScope.d.ts +1 -1
  141. package/dist/state/themeStore.d.ts +69 -16
  142. package/dist/state/themeStore.js +65 -38
  143. package/llms-full.md +56 -8
  144. package/llms.txt +10 -1
  145. 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
- 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);
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
- 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;
134
148
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
135
149
  disabled: !!isDisabled,
136
- haptic: false,
137
- scaleTo: preset === "link" ? 1 : 0.97,
150
+ haptic: haptic ?? "setting",
151
+ scaleTo: preset === "link" ? 1 : interaction.pressedScale,
138
152
  });
139
- const showFocusRing = (event) => {
140
- // On web, pointer taps focus the element too, which left the ring visible
141
- // after every click. :focus-visible is only true for keyboard-driven
142
- // focus, so gate the ring on it; fall back to showing the ring when the
143
- // target can't be queried (non-DOM targets, older engines).
144
- let ringVisible = true;
145
- if (Platform.OS === "web") {
146
- const target = event?.nativeEvent?.target;
147
- if (target && typeof target.matches === "function") {
148
- try {
149
- ringVisible = target.matches(":focus-visible");
150
- }
151
- catch {
152
- ringVisible = true;
153
- }
154
- }
155
- }
156
- setFocused(ringVisible);
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)), disabled: isDisabled, children: (state) => (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(View, { style: [
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
- focused && !isDisabled && focusRingStyle,
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: 1,
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: 0.9,
333
+ opacity: interaction.pressedOpacity,
313
334
  },
314
335
  pressedMuted: {
315
336
  backgroundColor: theme.colors.muted,
316
337
  },
317
- pressedText: {
318
- opacity: 0.9,
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: 0.6,
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"),
@@ -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
  : {}),