@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,8 +1,16 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useCallback, useRef } from "react";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
+ import { Animated, Easing, Platform, Pressable, View, } from "react-native";
3
4
  import { SegmentedControl as NativeSegmentedControl } from "@expo/ui/community/segmented-control";
4
5
  import { useTheme } from "../hooks/useTheme.js";
5
- import { hapticLight } from "../lib/haptics.js";
6
+ import { useReducedMotion } from "../hooks/useReduceMotion.js";
7
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
8
+ import { hapticSelection } from "../lib/haptics.js";
9
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
10
+ import { spacing } from "../constants/spacing.js";
11
+ import { interaction } from "../constants/interaction.js";
12
+ import { shouldUseNativeDriver } from "../lib/animations.js";
13
+ import { StyledText } from "./StyledText.js";
6
14
  function SegmentedControl({ values, value: controlledValue, defaultValue, onValueChange, disabled = false, tintColor, appearance, style, }) {
7
15
  const { theme } = useTheme();
8
16
  const [internalValue, setInternalValue] = React.useState(defaultValue);
@@ -14,12 +22,94 @@ function SegmentedControl({ values, value: controlledValue, defaultValue, onValu
14
22
  const nextIndex = values.indexOf(next);
15
23
  if (nextIndex !== lastIndex.current) {
16
24
  lastIndex.current = nextIndex;
17
- hapticLight();
25
+ hapticSelection();
18
26
  }
19
27
  if (!isControlled)
20
28
  setInternalValue(next);
21
29
  onValueChange?.(next);
22
30
  }, [isControlled, onValueChange, values]);
31
+ if (Platform.OS === "web") {
32
+ return (_jsx(WebSegmentedControl, { values: values, selectedIndex: selectedIndex, disabled: disabled, tintColor: tintColor, onSelect: handleValueChange, style: style }));
33
+ }
23
34
  return (_jsx(NativeSegmentedControl, { values: values, selectedIndex: selectedIndex, enabled: !disabled, onValueChange: handleValueChange, tintColor: tintColor ?? theme.colors.accent, appearance: appearance ?? (theme.dark ? "dark" : "light"), style: style }));
24
35
  }
36
+ // ---------------------------------------------------------------------------
37
+ // Web
38
+ // ---------------------------------------------------------------------------
39
+ /** Matches the Button `md` / vendored control height. */
40
+ const TRACK_HEIGHT = 32;
41
+ /** Gap between the track edge and the selected pill. */
42
+ const PILL_INSET = 2;
43
+ function WebSegmentedControl({ values, selectedIndex, disabled, tintColor, onSelect, style }) {
44
+ const { theme, getContrastingColor } = useTheme();
45
+ const reduceMotion = useReducedMotion();
46
+ const [trackWidth, setTrackWidth] = useState(0);
47
+ const position = useAnimatedValue(selectedIndex);
48
+ useEffect(() => {
49
+ const slide = Animated.timing(position, {
50
+ toValue: selectedIndex,
51
+ duration: reduceMotion ? 0 : 200,
52
+ easing: Easing.out(Easing.quad),
53
+ useNativeDriver: shouldUseNativeDriver,
54
+ });
55
+ slide.start();
56
+ // Stop on unmount (and before the next selection starts its own slide) so
57
+ // no animation frame outlives the control.
58
+ return () => slide.stop();
59
+ }, [position, reduceMotion, selectedIndex]);
60
+ const onLayout = (event) => setTrackWidth(event.nativeEvent.layout.width);
61
+ const count = Math.max(1, values.length);
62
+ const segmentWidth = Math.max(0, trackWidth - PILL_INSET * 2) / count;
63
+ const travel = Math.max(1, count - 1);
64
+ const pillColor = tintColor ?? theme.colors.accent;
65
+ // A custom tint may not pair with `accentForeground`; pick the readable one.
66
+ const selectedLabelColor = tintColor ? getContrastingColor(tintColor) : theme.colors.accentForeground;
67
+ return (_jsxs(View, { testID: "segmented-control", accessibilityState: { disabled }, onLayout: onLayout, style: [
68
+ {
69
+ height: TRACK_HEIGHT,
70
+ borderRadius: spacing.radiusMd,
71
+ backgroundColor: theme.colors.muted,
72
+ opacity: disabled ? interaction.disabledOpacity : 1,
73
+ overflow: "hidden",
74
+ alignSelf: "stretch",
75
+ },
76
+ style,
77
+ ], children: [segmentWidth > 0 && (_jsx(Animated.View, { style: {
78
+ pointerEvents: "none",
79
+ position: "absolute",
80
+ top: PILL_INSET,
81
+ bottom: PILL_INSET,
82
+ left: PILL_INSET,
83
+ width: segmentWidth,
84
+ borderRadius: spacing.radiusSm,
85
+ backgroundColor: pillColor,
86
+ transform: [
87
+ {
88
+ translateX: position.interpolate({
89
+ inputRange: [0, travel],
90
+ outputRange: [0, segmentWidth * travel],
91
+ }),
92
+ },
93
+ ],
94
+ } })), _jsx(View, { style: { position: "absolute", top: PILL_INSET, bottom: PILL_INSET, left: PILL_INSET, right: PILL_INSET, flexDirection: "row" }, children: values.map((label, index) => (_jsx(WebSegment, { label: label, selected: index === selectedIndex, disabled: disabled, color: index === selectedIndex ? selectedLabelColor : theme.colors.mutedForeground, onPress: () => onSelect(label) }, `${index}-${label}`))) })] }));
95
+ }
96
+ function WebSegment({ label, selected, disabled, color, onPress }) {
97
+ const { getFocusRingStyle } = useTheme();
98
+ const focus = useFocusVisible();
99
+ return (_jsx(Pressable, { accessibilityRole: "button", accessibilityState: { selected, disabled }, disabled: disabled, onPress: disabled ? undefined : onPress, onFocus: focus.onFocus, onBlur: focus.onBlur, style: [
100
+ {
101
+ flex: 1,
102
+ alignItems: "center",
103
+ justifyContent: "center",
104
+ paddingHorizontal: spacing.sm,
105
+ borderRadius: spacing.radiusSm,
106
+ ...(Platform.OS === "web" && {
107
+ cursor: (disabled ? "not-allowed" : "pointer"),
108
+ userSelect: "none",
109
+ outlineStyle: "none",
110
+ }),
111
+ },
112
+ focus.focused && !disabled && getFocusRingStyle(),
113
+ ], children: _jsx(StyledText, { selectable: false, numberOfLines: 1, fontWeight: "medium", style: { fontSize: 14, lineHeight: 18, color }, children: label }) }));
114
+ }
25
115
  export { SegmentedControl };
@@ -3,9 +3,12 @@ import * as React from "react";
3
3
  import { Animated, Platform, StyleSheet, View } from "react-native";
4
4
  import { Icon } from "./Icon.js";
5
5
  import { AnimatedView } from "./AnimatedView.js";
6
- import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context";
6
+ import { TextClassContext, TextColorContext, TextSelectabilityContext } from "./StyledText.context.js";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
+ import { shapeRadius, useShape } from "../hooks/useShape.js";
9
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
8
10
  import { spacing } from "../constants/spacing.js";
11
+ import { interaction } from "../constants/interaction.js";
9
12
  import { useScalePress } from "../hooks/useScalePress.js";
10
13
  import * as SelectPrimitive from "@rn-primitives/select";
11
14
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
@@ -36,34 +39,18 @@ const SIZE_CONFIGS = {
36
39
  };
37
40
  function SelectTrigger({ size = "md", error = false, children, style: styleOverride, disabled, ...props }) {
38
41
  const { theme, getFocusRingStyle } = useTheme();
42
+ const inputShape = useShape("input");
39
43
  const sizeConfig = SIZE_CONFIGS[size];
40
44
  const focusRingStyle = getFocusRingStyle();
41
- const [focused, setFocused] = React.useState(false);
42
45
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
43
46
  disabled: !!disabled,
44
47
  scaleTo: 0.97,
45
48
  haptic: false,
46
49
  });
47
- const showFocusRing = (event) => {
48
- let ringVisible = true;
49
- if (Platform.OS === "web") {
50
- const target = event?.nativeEvent?.target;
51
- if (target && typeof target.matches === "function") {
52
- try {
53
- ringVisible = target.matches(":focus-visible");
54
- }
55
- catch {
56
- ringVisible = true;
57
- }
58
- }
59
- }
60
- setFocused(ringVisible);
61
- };
62
- const hideFocusRing = () => {
63
- setFocused(false);
64
- };
50
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible();
65
51
  return (_jsx(Animated.View, { style: scaleStyle, children: _jsx(SelectPrimitive.Trigger, { disabled: disabled, ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
66
52
  ...styles.trigger,
53
+ ...shapeRadius(inputShape),
67
54
  height: sizeConfig.height,
68
55
  paddingHorizontal: sizeConfig.paddingHorizontal,
69
56
  borderColor: error ? theme.colors.destructive : theme.colors.border,
@@ -73,7 +60,7 @@ function SelectTrigger({ size = "md", error = false, children, style: styleOverr
73
60
  outlineStyle: "none",
74
61
  userSelect: "none",
75
62
  }),
76
- ...(disabled && { opacity: 0.5 }),
63
+ ...(disabled && { opacity: interaction.disabledOpacity }),
77
64
  ...(focused && !disabled ? focusRingStyle : null),
78
65
  ...(styleOverride && typeof styleOverride !== "function"
79
66
  ? StyleSheet.flatten(styleOverride)
@@ -100,7 +87,7 @@ function SelectContent({ side, align = "start", sideOffset = 4, portalHost, styl
100
87
  return (_jsx(SelectPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(SelectPrimitive.Overlay, { style: Platform.select({
101
88
  native: StyleSheet.absoluteFill,
102
89
  default: undefined,
103
- }), children: _jsx(AnimatedView, { type: "fade", style: StyleSheet.absoluteFill, pointerEvents: "box-none", children: _jsx(TextColorContext.Provider, { value: theme.colors.popoverForeground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(SelectPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
90
+ }), children: _jsx(AnimatedView, { type: "fade", style: [StyleSheet.absoluteFill, styles.passThrough], children: _jsx(TextColorContext.Provider, { value: theme.colors.popoverForeground, children: _jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(SelectPrimitive.Content, { side: side, align: align, sideOffset: sideOffset, insets: insets, avoidCollisions: true, ...props, style: {
104
91
  backgroundColor: theme.colors.popover,
105
92
  borderWidth: 1,
106
93
  borderColor: theme.colors.border,
@@ -128,17 +115,22 @@ function SelectItem({ children, style: styleOverride, ...props }) {
128
115
  const hasCustomChildren = React.isValidElement(children) || Array.isArray(children);
129
116
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
130
117
  disabled: !!props.disabled,
131
- scaleTo: 0.97,
118
+ scaleTo: interaction.pressedScale,
132
119
  haptic: false,
133
120
  });
134
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(Animated.View, { style: scaleStyle, children: _jsxs(SelectPrimitive.Item, { ...props, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, style: {
121
+ // On web the primitive's item is a Radix `div` that forwards every prop to
122
+ // the DOM, so RN press handlers would land there as unknown attributes
123
+ // ("Unknown event handler property onPressIn"). Native renders a Pressable
124
+ // and keeps the press scale.
125
+ const itemPressHandlers = Platform.OS === "web" ? undefined : pressHandlers;
126
+ return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(Animated.View, { style: scaleStyle, children: _jsxs(SelectPrimitive.Item, { ...props, ...itemPressHandlers, style: {
135
127
  ...styles.item,
136
128
  ...(Platform.OS === "web" && {
137
129
  cursor: props.disabled ? "not-allowed" : "pointer",
138
130
  outlineStyle: "none",
139
131
  userSelect: "none",
140
132
  }),
141
- ...(props.disabled && { opacity: 0.5 }),
133
+ ...(props.disabled && { opacity: interaction.disabledOpacity }),
142
134
  ...(styleOverride && typeof styleOverride !== "function"
143
135
  ? StyleSheet.flatten(styleOverride)
144
136
  : {}),
@@ -188,7 +180,10 @@ function SelectSeparator({ style: styleOverride, ...props }) {
188
180
  : {}),
189
181
  } }));
190
182
  }
191
- const styles = StyleSheet.create({
183
+ const styles = /*#__PURE__*/ StyleSheet.create({
184
+ // Overlay layer: covers the screen without swallowing touches (style, not the
185
+ // deprecated `pointerEvents` prop).
186
+ passThrough: { pointerEvents: "box-none" },
192
187
  trigger: {
193
188
  flexDirection: "row",
194
189
  justifyContent: "space-between",
@@ -1,10 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useRef } from "react";
2
+ import { useEffect } from "react";
3
3
  import { View, StyleSheet, Animated } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
+ import { shapeRadius, useShape } from "../hooks/useShape.js";
5
6
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
6
7
  import { spacing } from "../constants/spacing.js";
7
8
  import { createThemedStyles } from "../lib/themedStyles.js";
9
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
10
+ import { shouldUseNativeDriver } from "../lib/animations.js";
11
+ // ============================================================================
12
+ // Base Skeleton
13
+ // ============================================================================
14
+ /** Low point of the pulse; high enough that the bar never disappears. */
15
+ const PULSE_MIN = 0.55;
16
+ /** Opacity held when the OS asks for reduced motion (no pulse at all). */
17
+ const STATIC_OPACITY = 0.8;
8
18
  /**
9
19
  * Skeleton Component
10
20
  *
@@ -20,23 +30,23 @@ import { createThemedStyles } from "../lib/themedStyles.js";
20
30
  export function Skeleton({ width = "100%", height = 20, borderRadius = spacing.radiusMd, circle = false, style, }) {
21
31
  const { theme } = useTheme();
22
32
  const reduceMotion = useReducedMotion();
23
- const opacity = useRef(new Animated.Value(reduceMotion ? 0.6 : 0.3)).current;
33
+ const opacity = useAnimatedValue(reduceMotion ? STATIC_OPACITY : PULSE_MIN);
24
34
  useEffect(() => {
25
35
  if (reduceMotion) {
26
- opacity.setValue(0.6);
36
+ opacity.setValue(STATIC_OPACITY);
27
37
  return;
28
38
  }
29
- opacity.setValue(0.3);
39
+ opacity.setValue(PULSE_MIN);
30
40
  const animation = Animated.loop(Animated.sequence([
31
41
  Animated.timing(opacity, {
32
42
  toValue: 1,
33
43
  duration: 800,
34
- useNativeDriver: true,
44
+ useNativeDriver: shouldUseNativeDriver,
35
45
  }),
36
46
  Animated.timing(opacity, {
37
- toValue: 0.3,
47
+ toValue: PULSE_MIN,
38
48
  duration: 800,
39
- useNativeDriver: true,
49
+ useNativeDriver: shouldUseNativeDriver,
40
50
  }),
41
51
  ]));
42
52
  animation.start();
@@ -48,7 +58,9 @@ export function Skeleton({ width = "100%", height = 20, borderRadius = spacing.r
48
58
  width: circle ? resolvedSize : width,
49
59
  height: circle ? resolvedSize : height,
50
60
  borderRadius: circle ? (resolvedSize / 2) : borderRadius,
51
- backgroundColor: theme.colors.muted,
61
+ // `borderStrong`, not `muted`: on a white card `muted` (#F4F4F5) is
62
+ // a 1.05:1 step and the pulse's low point made it vanish outright.
63
+ backgroundColor: theme.colors.borderStrong,
52
64
  },
53
65
  { opacity },
54
66
  style,
@@ -74,7 +86,9 @@ export function SkeletonAvatar({ size = 40, style }) {
74
86
  export function SkeletonCard({ showImage = true, imageHeight = 140, showAvatar = true, textLines = 3, style, }) {
75
87
  const { theme, getShadowStyle } = useTheme();
76
88
  const styles = themedStyles(theme);
77
- return (_jsxs(View, { style: [styles.card, getShadowStyle("subtle"), style], children: [showImage && (_jsx(Skeleton, { width: "100%", height: imageHeight, borderRadius: 0 })), _jsxs(View, { style: styles.body, children: [showAvatar && (_jsxs(View, { style: styles.avatarRow, children: [_jsx(SkeletonAvatar, { size: 36 }), _jsxs(View, { style: styles.avatarText, children: [_jsx(Skeleton, { width: "50%", height: 14 }), _jsx(Skeleton, { width: "30%", height: 12 })] })] })), _jsx(SkeletonText, { lines: textLines })] })] }));
89
+ // Host-app `setShape({ card })`: the placeholder keeps the Card's corners.
90
+ const cardRadius = shapeRadius(useShape("card"));
91
+ return (_jsxs(View, { style: [styles.card, cardRadius, getShadowStyle("subtle"), style], children: [showImage && (_jsx(Skeleton, { width: "100%", height: imageHeight, borderRadius: 0 })), _jsxs(View, { style: styles.body, children: [showAvatar && (_jsxs(View, { style: styles.avatarRow, children: [_jsx(SkeletonAvatar, { size: 36 }), _jsxs(View, { style: styles.avatarText, children: [_jsx(Skeleton, { width: "50%", height: 14 }), _jsx(Skeleton, { width: "30%", height: 12 })] })] })), _jsx(SkeletonText, { lines: textLines })] })] }));
78
92
  }
79
93
  const createCardStyles = (theme) => StyleSheet.create({
80
94
  card: {
@@ -98,4 +112,4 @@ const createCardStyles = (theme) => StyleSheet.create({
98
112
  gap: spacing.xs,
99
113
  },
100
114
  });
101
- const themedStyles = createThemedStyles(createCardStyles);
115
+ const themedStyles = /*#__PURE__*/ createThemedStyles(createCardStyles);
@@ -6,15 +6,23 @@ import { StyleProp, ViewStyle } from "react-native";
6
6
  *
7
7
  * - iOS: SwiftUI `Slider`
8
8
  * - Android: Material 3 `Slider`
9
- * - Web: native `<input type="range">` (themed via `accentColor`)
9
+ * - Web: native `<input type="range">`, drawn by the kit's own
10
+ * stylesheet (see `SLIDER_WEB_CSS`)
10
11
  *
11
12
  * The public `SliderProps` surface (value / onValueChange / min / max / step /
12
13
  * disabled / showValue / size / style) is preserved, and the active track is
13
14
  * themed with the design system's accent color on every platform. Thumb and
14
- * inactive-track tints additionally apply on Android (iOS/web draw the system
15
- * thumb). Haptic feedback fires on each step change, matching the prior
15
+ * inactive-track tints additionally apply on Android and web (iOS draws the
16
+ * system thumb). Haptic feedback fires on each step change, matching the prior
16
17
  * hand-rolled slider.
17
18
  *
19
+ * Web: `@expo/ui` only maps `minimumTrackTintColor` to CSS `accent-color`, and
20
+ * Chromium then paints the unfilled track dark in light mode. The kit sets
21
+ * `appearance: none` on the input and draws the track, fill and thumb from
22
+ * CSS variables it puts on the element, through one hoisted `<style>` (React
23
+ * dedupes it by `href`). The stylesheet is scoped to `[data-expo-ui-slider]`,
24
+ * so other range inputs on the page keep their look.
25
+ *
18
26
  * Platform-owned behaviors (props accepted for ergonomics, but the platform
19
27
  * decides):
20
28
  * - `size` is accepted for call-site compatibility but has no effect — the
@@ -1,17 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { palette } from "../constants/colors.js";
3
+ import { interaction } from "../constants/interaction.js";
3
4
  import { useTheme } from "../hooks/useTheme.js";
4
5
  import { hapticLight } from "../lib/haptics.js";
5
6
  import { stateSurfaceProps } from "../lib/stateSurface.js";
6
- import { useCallback, useRef } from "react";
7
- import { View } from "react-native";
7
+ import React, { useCallback, useRef } from "react";
8
+ import { Platform, View } from "react-native";
8
9
  import { Slider as NativeSlider } from "@expo/ui/community/slider";
9
10
  import { StyledText } from "./StyledText.js";
10
11
  function Slider({ value = 0, onValueChange, min = 0, max = 100, step = 1,
11
12
  // Accepted for call-site compatibility; the platform owns track/thumb sizing.
12
13
  size: _size = "md", disabled = false, showValue = false, style: styleOverride, }) {
13
14
  const { theme, withAlpha } = useTheme();
14
- const inactiveTrackColor = theme.dark ? withAlpha(palette.white, 0.1) : theme.colors.muted;
15
+ // Light: `border`, not `muted` — on a card `muted` is a 1.05:1 step and the
16
+ // unfilled track disappeared. Dark unchanged.
17
+ const inactiveTrackColor = theme.dark ? withAlpha(palette.white, 0.1) : theme.colors.border;
15
18
  const activeTrackColor = disabled
16
19
  ? theme.dark
17
20
  ? withAlpha(palette.white, 0.28)
@@ -32,15 +35,79 @@ size: _size = "md", disabled = false, showValue = false, style: styleOverride, }
32
35
  }
33
36
  onValueChange?.(next);
34
37
  }, [onValueChange]);
35
- return (_jsxs(View, { ...stateSurfaceProps(), style: [{ opacity: disabled ? 0.5 : 1, alignSelf: "stretch" }, styleOverride], children: [showValue && (_jsx(StyledText, { selectable: false, style: {
38
+ return (_jsxs(View, { ...stateSurfaceProps(), ...webSliderScope, style: [{ opacity: disabled ? interaction.disabledOpacity : 1, alignSelf: "stretch" }, styleOverride], children: [Platform.OS === "web" && sliderWebStylesheet, showValue && (_jsx(StyledText, { selectable: false, style: {
36
39
  fontSize: 12,
37
40
  color: theme.colors.textDim,
38
41
  marginBottom: 4,
39
42
  userSelect: "none",
40
- }, children: value })), _jsx(NativeSlider, { value: value, minimumValue: min, maximumValue: max, step: step, disabled: disabled, onValueChange: handleValueChange, style: { width: "100%" },
43
+ }, children: value })), _jsx(NativeSlider, { value: value, minimumValue: min, maximumValue: max, step: step, disabled: disabled, onValueChange: handleValueChange, style: [
44
+ { width: "100%" },
45
+ Platform.OS === "web" &&
46
+ webSliderVariables({
47
+ fill: fillRatio(value, min, max),
48
+ track: inactiveTrackColor,
49
+ active: activeTrackColor,
50
+ thumb: thumbTintColor,
51
+ ring: theme.colors.ring,
52
+ ringBackground: theme.colors.background,
53
+ }),
54
+ ],
41
55
  // Active track — honored on iOS, Android, and web (`accentColor`).
42
56
  minimumTrackTintColor: activeTrackColor,
43
- // Inactive track + thumb — honored on Android; system-drawn elsewhere.
57
+ // Inactive track + thumb — honored on Android (and web through the
58
+ // CSS variables above); system-drawn on iOS.
44
59
  maximumTrackTintColor: inactiveTrackColor, thumbTintColor: thumbTintColor })] }));
45
60
  }
61
+ /** Filled share of the track, 0–1, for the web fill gradient. */
62
+ function fillRatio(value, min, max) {
63
+ if (!(max > min))
64
+ return 0;
65
+ return Math.min(1, Math.max(0, (value - min) / (max - min)));
66
+ }
67
+ /**
68
+ * CSS custom properties the web stylesheet reads. They ride on the input's
69
+ * inline `style` (React DOM writes `--*` keys as custom properties), so each
70
+ * slider carries its own theme values and the stylesheet stays static.
71
+ */
72
+ function webSliderVariables(v) {
73
+ return {
74
+ "--expo-ui-slider-fill": `${Number((v.fill * 100).toFixed(2))}%`,
75
+ "--expo-ui-slider-track": v.track,
76
+ "--expo-ui-slider-active": v.active,
77
+ "--expo-ui-slider-thumb": v.thumb,
78
+ "--expo-ui-slider-ring": v.ring,
79
+ "--expo-ui-slider-ring-bg": v.ringBackground,
80
+ };
81
+ }
82
+ /** `data-expo-ui-slider` on the wrapper (react-native-web's `dataSet`), scoping the stylesheet. */
83
+ const webSliderScope = Platform.OS === "web" ? { dataSet: { expoUiSlider: "" } } : {};
84
+ const TRACK = "[data-expo-ui-slider] input[type=\"range\"]";
85
+ const THUMB_SHADOW = "0 1px 3px rgba(0,0,0,.25)";
86
+ const RING_SHADOW = "0 0 0 2px var(--expo-ui-slider-ring-bg),0 0 0 4px var(--expo-ui-slider-ring)";
87
+ const SLIDER_WEB_CSS = [
88
+ `${TRACK}{-webkit-appearance:none;appearance:none;width:100%;height:24px;margin:0;padding:0;background:transparent;cursor:pointer;outline:none;--expo-ui-slider-fill:0%}`,
89
+ `${TRACK}:disabled{cursor:not-allowed}`,
90
+ `${TRACK}::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:linear-gradient(var(--expo-ui-slider-active),var(--expo-ui-slider-active)) 0 0/var(--expo-ui-slider-fill) 100% no-repeat var(--expo-ui-slider-track)}`,
91
+ `${TRACK}::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:20px;height:20px;margin-top:-8px;border:0;border-radius:50%;background:var(--expo-ui-slider-thumb);box-shadow:${THUMB_SHADOW}}`,
92
+ `${TRACK}:focus-visible::-webkit-slider-thumb{box-shadow:${RING_SHADOW}}`,
93
+ `${TRACK}::-moz-range-track{height:4px;border-radius:2px;background:var(--expo-ui-slider-track)}`,
94
+ `${TRACK}::-moz-range-progress{height:4px;border-radius:2px;background:var(--expo-ui-slider-active)}`,
95
+ `${TRACK}::-moz-range-thumb{width:20px;height:20px;border:0;border-radius:50%;background:var(--expo-ui-slider-thumb);box-shadow:${THUMB_SHADOW}}`,
96
+ `${TRACK}:focus-visible::-moz-range-thumb{box-shadow:${RING_SHADOW}}`,
97
+ ].join("");
98
+ /**
99
+ * One hoistable stylesheet (React 19 `<style href precedence>`): rendered by
100
+ * every web Slider, mounted in `<head>` once, and part of the server markup
101
+ * so the first paint is already themed. The CSS goes through
102
+ * `dangerouslySetInnerHTML` rather than a text child so the element stays
103
+ * representable outside the DOM renderer (the RN test renderer rejects text
104
+ * outside `<Text>`); the string is a package constant, never user input.
105
+ */
106
+ const sliderWebStylesheet = Platform.OS === "web"
107
+ ? React.createElement("style", {
108
+ href: "expo-ui-slider",
109
+ precedence: "default",
110
+ dangerouslySetInnerHTML: { __html: SLIDER_WEB_CSS },
111
+ })
112
+ : null;
46
113
  export { Slider };
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
  import { StyleProp, ViewStyle } from "react-native";
3
- import { type IconName } from "./Icon";
3
+ import { type IconName } from "./Icon.js";
4
4
  export type StatChangeDirection = "up" | "down" | "neutral";
5
5
  export interface StatCardChange {
6
6
  /** Signed change text, e.g. "+12.5%" or "-3 orders". */
@@ -30,7 +30,9 @@ export interface StatCardProps {
30
30
  * Dashboard stat idiom: a tiny uppercase tracked label, a large
31
31
  * tabular-nums value (with an optional muted unit suffix), and an optional
32
32
  * signed change line (success/destructive/muted by direction). Builds on
33
- * `Card` for the container, default shadow, and press feedback.
33
+ * `Card` for the container, default shadow, and press feedback, so it follows
34
+ * Card's rule: a tile in a collection (a scrolling metrics rail) or one
35
+ * tappable metric, not a box around every number on a summary screen.
34
36
  *
35
37
  * @example
36
38
  * ```tsx
@@ -16,7 +16,9 @@ const DIRECTION_ICON = {
16
16
  * Dashboard stat idiom: a tiny uppercase tracked label, a large
17
17
  * tabular-nums value (with an optional muted unit suffix), and an optional
18
18
  * signed change line (success/destructive/muted by direction). Builds on
19
- * `Card` for the container, default shadow, and press feedback.
19
+ * `Card` for the container, default shadow, and press feedback, so it follows
20
+ * Card's rule: a tile in a collection (a scrolling metrics rail) or one
21
+ * tappable metric, not a box around every number on a summary screen.
20
22
  *
21
23
  * @example
22
24
  * ```tsx
@@ -1,6 +1,6 @@
1
1
  import { type ComponentRef, type Ref } from "react";
2
2
  import { Text as RNText, TextProps as RNTextProps } from "react-native";
3
- import { type FontVariant } from "../constants/fonts";
3
+ import { type FontVariant } from "../constants/fonts.js";
4
4
  /**
5
5
  * Font size variants for the type scale (Inter sans-serif / Georgia serif)
6
6
  */
@@ -27,6 +27,12 @@ export type TextProps = RNTextProps & {
27
27
  * Font weight - light, regular, medium, semibold, bold
28
28
  */
29
29
  fontWeight?: FontWeight;
30
+ /**
31
+ * Italic. A real italic face when the family has one (an app's `setFonts`
32
+ * `italic` map, or the Newsreader serif preset); otherwise `fontStyle:
33
+ * "italic"` and the platform synthesizes the slant.
34
+ */
35
+ italic?: boolean;
30
36
  /**
31
37
  * Font size variant
32
38
  */
@@ -5,7 +5,7 @@ import { useTheme } from "../hooks/useTheme.js";
5
5
  import { resolveFontStyle } from "../constants/fonts.js";
6
6
  import { useThemeStore } from "../state/themeStore.js";
7
7
  import { translateText } from "../lib/i18n.js";
8
- import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context";
8
+ import { TextColorContext, TextSelectabilityContext, TextStyleContext } from "./StyledText.context.js";
9
9
  const FONT_SIZES = {
10
10
  xs: 11,
11
11
  sm: 12,
@@ -76,11 +76,12 @@ const SEMANTIC_CONFIGS = {
76
76
  * - numberOfLines and ellipsizeMode support from RN TextProps
77
77
  */
78
78
  export function StyledText(props) {
79
- const { tx, text, txOptions, style, variant = "sansSerif", fontWeight, size, semantic, align, selectable, children, ref, ...otherProps } = props;
79
+ const { tx, text, txOptions, style, variant = "sansSerif", fontWeight, italic, size, semantic, align, selectable, children, ref, ...otherProps } = props;
80
80
  const { theme } = useTheme();
81
81
  // Subscribed, not read via getState(), so a `setFonts` call after mount
82
82
  // re-renders text instead of leaving stale families on screen.
83
83
  const fontOverrides = useThemeStore((s) => s.fontOverrides);
84
+ const serifPreset = useThemeStore((s) => s.serifPreset);
84
85
  // Check if there's a color override from parent context (e.g., Button)
85
86
  const contextColor = use(TextColorContext);
86
87
  const contextTextStyle = use(TextStyleContext);
@@ -99,7 +100,7 @@ export function StyledText(props) {
99
100
  // faces via `setFonts`; resolution — including the per-group merge and the
100
101
  // numeric-vs-family weight strategy — lives in `resolveFontStyle`
101
102
  // (constants/fonts.ts), shared with the control components' `useFontStyle`.
102
- const { fontFamily, fontWeight: resolvedFontWeight } = resolveFontStyle(fontOverrides, variant, finalFontWeight);
103
+ const { fontFamily, fontWeight: resolvedFontWeight, fontStyle } = resolveFontStyle(fontOverrides, variant, finalFontWeight, { italic, serifPreset });
103
104
  // Get fontSize and lineHeight from size variant
104
105
  const fontSize = FONT_SIZES[finalSize];
105
106
  const lineHeight = LINE_HEIGHTS[finalSize];
@@ -124,6 +125,7 @@ export function StyledText(props) {
124
125
  fontFamily,
125
126
  fontSize,
126
127
  ...(resolvedFontWeight !== undefined && { fontWeight: resolvedFontWeight }),
128
+ ...(fontStyle !== undefined && { fontStyle }),
127
129
  ...(resolvedLineHeight !== undefined && { lineHeight: resolvedLineHeight }),
128
130
  userSelect: resolvedSelectable ? "auto" : "none",
129
131
  ...(letterSpacing !== undefined && { letterSpacing }),
@@ -41,5 +41,5 @@ interface SwitchProps extends Omit<SwitchPrimitives.RootProps, "style"> {
41
41
  */
42
42
  style?: StyleProp<ViewStyle>;
43
43
  }
44
- declare function Switch({ variant, labelOn, labelOff, size, thumbSize, loading, style: styleOverride, ...props }: SwitchProps): React.JSX.Element;
44
+ declare function Switch({ variant, labelOn, labelOff, size, thumbSize, loading, style: styleOverride, onCheckedChange, ...props }: SwitchProps): React.JSX.Element;
45
45
  export { Switch };
@@ -2,61 +2,51 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { palette } from "../constants/colors.js";
3
3
  import { spacing } from "../constants/spacing.js";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
- import { hapticLight } from "../lib/haptics.js";
5
+ import { useFocusVisible } from "../hooks/useFocusVisible.js";
6
+ import { hapticSelection } from "../lib/haptics.js";
7
+ import { interaction } from "../constants/interaction.js";
6
8
  import { stateSurfaceProps } from "../lib/stateSurface.js";
9
+ import { useAnimatedValue } from "../lib/useAnimatedValue.js";
7
10
  import * as SwitchPrimitives from "@rn-primitives/switch";
8
- import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { useCallback, useEffect, useRef } from "react";
9
12
  import { ActivityIndicator, Platform, StyleSheet, View, Animated } from "react-native";
10
13
  import { StyledText } from "./StyledText.js";
11
14
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
12
15
  import { useScalePress } from "../hooks/useScalePress.js";
16
+ import { shouldUseNativeDriver } from "../lib/animations.js";
17
+ import { useItemControlLabel } from "./Item.js";
13
18
  const DEFAULT_HIT_SLOP = 8;
14
- function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, height: 24 }, thumbSize = 20, loading = false, style: styleOverride, ...props }) {
19
+ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, height: 24 }, thumbSize = 20, loading = false, style: styleOverride, onCheckedChange, ...props }) {
15
20
  const { theme, getContrastingColor, getShadowStyle, getFocusRingStyle, withAlpha } = useTheme();
16
21
  const reduceMotion = useReducedMotion();
17
22
  const hasMounted = useRef(false);
18
- const [focused, setFocused] = useState(false);
19
23
  const focusRingStyle = getFocusRingStyle();
20
24
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
21
25
  disabled: !!props.disabled,
22
- scaleTo: 0.92,
26
+ scaleTo: interaction.controlPressedScale,
23
27
  haptic: false,
24
28
  });
25
- const showFocusRing = (event) => {
26
- let ringVisible = true;
27
- if (Platform.OS === "web") {
28
- const target = event?.nativeEvent?.target;
29
- if (target && typeof target.matches === "function") {
30
- try {
31
- ringVisible = target.matches(":focus-visible");
32
- }
33
- catch {
34
- ringVisible = true;
35
- }
36
- }
37
- }
38
- setFocused(ringVisible);
39
- };
40
- const hideFocusRing = () => {
41
- setFocused(false);
42
- };
29
+ const { focused, onFocus: showFocusRing, onBlur: hideFocusRing } = useFocusVisible();
30
+ // Inside an Item row with no label of its own, the switch is named by the
31
+ // row title (aria-labelledby on web, accessibilityLabel on native).
32
+ const rowLabel = useItemControlLabel(props);
43
33
  // Fire haptic on user-initiated toggles (skip initial mount)
44
34
  const wrappedOnCheckedChange = useCallback((checked) => {
45
35
  if (hasMounted.current)
46
- hapticLight();
47
- props.onCheckedChange?.(checked);
48
- }, [props.onCheckedChange]);
36
+ hapticSelection();
37
+ onCheckedChange?.(checked);
38
+ }, [onCheckedChange]);
49
39
  useEffect(() => {
50
40
  hasMounted.current = true;
51
41
  }, []);
52
42
  // Single animated value drives everything: 0 = off, 1 = on
53
- const progress = useRef(new Animated.Value(props.checked ? 1 : 0)).current;
43
+ const progress = useAnimatedValue(props.checked ? 1 : 0);
54
44
  useEffect(() => {
55
45
  const target = props.checked ? 1 : 0;
56
46
  Animated.timing(progress, {
57
47
  toValue: target,
58
48
  duration: reduceMotion ? 0 : 120,
59
- useNativeDriver: true,
49
+ useNativeDriver: shouldUseNativeDriver,
60
50
  }).start();
61
51
  }, [props.checked, reduceMotion, progress]);
62
52
  // Thumb slides from left to right with equal inset on every side.
@@ -93,7 +83,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
93
83
  const labelFontSize = size.height / 3;
94
84
  // Flatten style override for web compatibility
95
85
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
96
- return (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(SwitchPrimitives.Root, { ...props, onCheckedChange: wrappedOnCheckedChange, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
86
+ return (_jsx(Animated.View, { style: scaleStyle, children: _jsxs(SwitchPrimitives.Root, { ...rowLabel, ...props, onCheckedChange: wrappedOnCheckedChange, onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, onFocus: showFocusRing, onBlur: hideFocusRing, style: {
97
87
  position: "relative",
98
88
  width: size.width,
99
89
  height: size.height,
@@ -148,7 +138,7 @@ function Switch({ variant = "default", labelOn, labelOff, size = { width: 44, he
148
138
  userSelect: "none",
149
139
  }, children: labelOff }) }))] }) }));
150
140
  }
151
- const styles = StyleSheet.create({
141
+ const styles = /*#__PURE__*/ StyleSheet.create({
152
142
  label: {
153
143
  position: "absolute",
154
144
  top: 0,