@mrmeg/expo-ui 0.14.0 → 0.16.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 (61) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +50 -6
  3. package/dist/components/Accordion.d.ts +1 -1
  4. package/dist/components/Accordion.js +25 -19
  5. package/dist/components/Badge.js +10 -7
  6. package/dist/components/BottomSheet.js +21 -19
  7. package/dist/components/Button.d.ts +2 -1
  8. package/dist/components/Button.js +22 -6
  9. package/dist/components/Card.js +5 -3
  10. package/dist/components/Checkbox.js +49 -19
  11. package/dist/components/Collapsible.d.ts +1 -1
  12. package/dist/components/Collapsible.js +22 -16
  13. package/dist/components/Drawer.js +8 -7
  14. package/dist/components/DropdownMenu.d.ts +1 -1
  15. package/dist/components/DropdownMenu.js +80 -59
  16. package/dist/components/EmptyState.d.ts +7 -1
  17. package/dist/components/EmptyState.js +26 -9
  18. package/dist/components/InputOTP.js +10 -4
  19. package/dist/components/Item.d.ts +93 -0
  20. package/dist/components/Item.js +125 -0
  21. package/dist/components/Label.js +10 -4
  22. package/dist/components/Notification.js +14 -24
  23. package/dist/components/Popover.d.ts +3 -18
  24. package/dist/components/Popover.js +10 -6
  25. package/dist/components/RadioGroup.js +49 -22
  26. package/dist/components/SectionHeader.d.ts +42 -0
  27. package/dist/components/SectionHeader.js +41 -0
  28. package/dist/components/Select.js +73 -41
  29. package/dist/components/Skeleton.js +5 -3
  30. package/dist/components/StatCard.d.ts +45 -0
  31. package/dist/components/StatCard.js +41 -0
  32. package/dist/components/StyledText.d.ts +20 -10
  33. package/dist/components/StyledText.js +85 -29
  34. package/dist/components/Switch.js +84 -57
  35. package/dist/components/Tabs.js +43 -12
  36. package/dist/components/TextInput.js +17 -11
  37. package/dist/components/Toggle.js +69 -42
  38. package/dist/components/ToggleGroup.js +82 -55
  39. package/dist/components/index.d.ts +3 -0
  40. package/dist/components/index.js +3 -0
  41. package/dist/constants/fonts.d.ts +44 -5
  42. package/dist/constants/fonts.js +42 -12
  43. package/dist/constants/index.d.ts +1 -0
  44. package/dist/constants/index.js +1 -0
  45. package/dist/constants/motion.d.ts +24 -0
  46. package/dist/constants/motion.js +24 -0
  47. package/dist/constants/spacing.d.ts +7 -7
  48. package/dist/constants/spacing.js +7 -7
  49. package/dist/hooks/useResources.d.ts +4 -2
  50. package/dist/hooks/useResources.js +30 -8
  51. package/dist/hooks/useStaggeredEntrance.d.ts +1 -1
  52. package/dist/hooks/useStaggeredEntrance.js +2 -1
  53. package/dist/hooks/useTheme.d.ts +1 -1
  54. package/dist/hooks/useTheme.js +53 -16
  55. package/dist/lib/index.d.ts +1 -0
  56. package/dist/lib/index.js +1 -0
  57. package/dist/lib/themedStyles.d.ts +21 -0
  58. package/dist/lib/themedStyles.js +33 -0
  59. package/dist/state/themeStore.d.ts +17 -0
  60. package/dist/state/themeStore.js +6 -0
  61. package/package.json +20 -19
package/CHANGELOG.md CHANGED
@@ -3,6 +3,87 @@
3
3
  All notable changes to `@mrmeg/expo-ui` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
5
 
6
+ ## [0.16.0]
7
+
8
+ ### Added
9
+
10
+ - **`setFonts` — font injection, mirroring `setColors`.** A host app can now
11
+ forward its own bundled faces to the package:
12
+
13
+ ```ts
14
+ useThemeStore.getState().setFonts({
15
+ families: { sansSerif: { light: "…", regular: "…", medium: "…",
16
+ semibold: "…", bold: "…" } },
17
+ webWeightStrategy: "family",
18
+ });
19
+ ```
20
+
21
+ Overrides are partial and per-group — pass `sansSerif` alone and `serif`
22
+ keeps the package default. `setFonts({})` clears back to the defaults.
23
+ `StyledText` subscribes to the store, so a call after mount re-renders
24
+ rather than leaving stale families on screen.
25
+
26
+ - **`webWeightStrategy` — `"numeric" | "family"`.** Declares how a family map
27
+ expresses weight. `"numeric"` (the package default on web) assumes one
28
+ multi-weight CSS family where a numeric `fontWeight` selects the
29
+ `@font-face` variant. `"family"` means each weight is its own
30
+ separately-registered single-face family, so the family name alone carries
31
+ the weight and `StyledText` suppresses the numeric `fontWeight`. Native is
32
+ always `"family"`, unchanged.
33
+
34
+ Apps loading per-weight faces through `expo-font` need `"family"` on web:
35
+ `expo-font` registers each file as its own family there too, so the previous
36
+ always-numeric behaviour synthesised a second layer of bold on top of an
37
+ already-bold face.
38
+
39
+ - Exported `FontFamilyMap`, `FontFamilyWeight`, `FontOverrides`,
40
+ `FontWeightStrategy`, and `defaultWebWeightStrategy` from `constants/fonts`.
41
+
42
+ - **`createThemedStyles` — SSR-safe themed style registration**, exported from
43
+ `lib`. Wraps a `(theme: Theme) => styles` factory so both base themes are
44
+ registered with react-native-web at module scope — before expo-router
45
+ snapshots the server-side stylesheet — instead of lazily during render via
46
+ `useMemo`, which left SSR HTML referencing class names with no rules
47
+ (unstyled first paint until hydration). Override themes are cached per theme
48
+ object identity. The package's own components (Badge, Button, Card,
49
+ EmptyState, InputOTP, Label, Notification, Skeleton, TextInput) now use it.
50
+
51
+ ### Why
52
+
53
+ `fontFamilies` was a module-level `const` with no injection point, so the only
54
+ way for a consumer to use its own type was to patch `dist/` inside
55
+ `node_modules`. Bun keys `patchedDependencies` on an exact `name@version`, so
56
+ those patches stop applying — silently, with no warning — the moment the
57
+ package version is bumped. Two consumer apps hit exactly that and shipped
58
+ fallback fonts without noticing. Colors already had `setColors` for this
59
+ reason; fonts now match.
60
+
61
+ Fully backward compatible: with no `setFonts` call, font resolution is
62
+ identical to 0.15.0.
63
+
64
+ ## [0.15.0]
65
+
66
+ ### Added
67
+
68
+ - **New composition primitives: `SectionHeader`, `StatCard`, and `Item`.**
69
+ `EmptyState` was also upgraded with richer layout options as part of the
70
+ same pass.
71
+ - **Typography system.** The package now loads Inter with real weights and a
72
+ letter-spacing scale via `useResources`, replacing synthetic bolding.
73
+ - Motion tokens, dual-layer shadow tokens, and softer radii in the design
74
+ tokens.
75
+
76
+ ### Changed
77
+
78
+ - Interaction sweep across components: press feedback, focus rings, and
79
+ shadow adoption.
80
+ - Updated all `@rn-primitives/*` dependencies from `~1.4.0` to `~1.5.2`,
81
+ picking up upstream's native accessibility overhaul: menus, popovers, and
82
+ dialogs are now usable with VoiceOver/TalkBack (focus moves into opened
83
+ content, escape gestures dismiss), toggles and switches announce the
84
+ correct state, and `nativeID` plumbing that broke Reanimated exiting
85
+ animations was removed.
86
+
6
87
  ## [0.14.0]
7
88
 
8
89
  ### Changed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mrmeg/expo-ui
2
2
 
3
- Reusable Expo and React Native UI primitives shared by the template and consumer apps. The package does not ship font files; web consumers load Lato from Google Fonts and native consumers use platform sans-serif fallbacks.
3
+ Reusable Expo and React Native UI primitives shared by the template and consumer apps. Typography is Inter: native consumers load four static Inter weights via `@expo-google-fonts/inter`, web consumers load Inter from Google Fonts.
4
4
 
5
5
  This package is public for installability, reuse across MrMeg projects, and
6
6
  discoverability. It is a personal reusable Expo / React Native design-system
@@ -163,6 +163,50 @@ import { ThemeColorScope } from "@mrmeg/expo-ui/state";
163
163
  </ThemeColorScope>
164
164
  ```
165
165
 
166
+ ### Font overrides
167
+
168
+ The package bundles Inter (sans) and Georgia (serif). Most apps ship their own
169
+ faces — forward them with `setFonts`, the font counterpart to `setColors`.
170
+ Call it once at startup, after your fonts are registered:
171
+
172
+ ```tsx
173
+ import { useThemeStore } from "@mrmeg/expo-ui/state";
174
+
175
+ useThemeStore.getState().setFonts({
176
+ families: {
177
+ sansSerif: {
178
+ light: "HankenGrotesk_300Light",
179
+ regular: "HankenGrotesk_400Regular",
180
+ medium: "HankenGrotesk_500Medium",
181
+ semibold: "HankenGrotesk_600SemiBold",
182
+ bold: "HankenGrotesk_700Bold",
183
+ },
184
+ },
185
+ webWeightStrategy: "family",
186
+ });
187
+ ```
188
+
189
+ Overrides are partial and per-group: pass `sansSerif` alone and `serif` keeps
190
+ the package default. `setFonts({})` clears back to the defaults.
191
+
192
+ **Pick the right `webWeightStrategy`** — this is the one non-obvious part:
193
+
194
+ | Strategy | Use when | Effect |
195
+ |---|---|---|
196
+ | `"numeric"` (default) | You ship **one multi-weight CSS family** (a variable font, or Google Fonts' single `"Inter"` family) | A numeric `fontWeight` selects the `@font-face` variant |
197
+ | `"family"` | You load **per-weight faces via `expo-font`** | The family name alone carries the weight; the numeric `fontWeight` is suppressed |
198
+
199
+ If you load fonts through `expo-font` / `@expo-google-fonts`, choose
200
+ `"family"`. `expo-font` registers each weight as its own single-face family on
201
+ web as well as native, so emitting a numeric weight on top makes the browser
202
+ synthesise a *second* layer of bold over an already-bold face.
203
+
204
+ Native always behaves as `"family"` regardless of this setting.
205
+
206
+ > Prefer `setFonts` over patching `node_modules`. Bun keys
207
+ > `patchedDependencies` to an exact `name@version`, so a font patch silently
208
+ > stops applying the next time you bump the package — with no warning.
209
+
166
210
  Use `StyledText` for theme-aware text:
167
211
 
168
212
  ```tsx
@@ -394,21 +438,21 @@ export default function RootLayout() {
394
438
 
395
439
  ## Fonts
396
440
 
397
- This package does not ship Lato `.ttf` files or other font binaries.
441
+ Typography is Inter across every platform, in four static weights (`Inter_400Regular`, `Inter_500Medium`, `Inter_600SemiBold`, `Inter_700Bold`) from `@expo-google-fonts/inter` — a package dependency, so consumers get it automatically.
398
442
 
399
- On web, `useResources()` injects the Google Fonts Lato stylesheet after hydration if the app has not already added it. For better first paint in Expo Router web apps, add the links in app-owned `app/+html.tsx`:
443
+ On native, `useResources()` loads the four static weights so `StyledText`'s `light`–`bold` range resolves to real font files instead of a faked OS bold. On web, `useResources()` injects a single Google Fonts Inter stylesheet (covering all four weights) after hydration if the app has not already added it; weight differentiation on web comes from a numeric `fontWeight` alongside the shared `"Inter"` CSS family. For better first paint in Expo Router web apps, add the links in app-owned `app/+html.tsx`:
400
444
 
401
445
  ```tsx
402
446
  <link rel="preconnect" href="https://fonts.googleapis.com" />
403
447
  <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
404
448
  <link
405
- id="mrmeg-expo-ui-lato"
449
+ id="mrmeg-expo-ui-inter"
406
450
  rel="stylesheet"
407
- href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap"
451
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
408
452
  />
409
453
  ```
410
454
 
411
- On native, the package uses platform sans-serif fallbacks. `useResources()` still loads `Feather.font` from the package-managed `@expo/vector-icons` dependency for icon rendering.
455
+ `useResources()` also loads `Feather.font` from the package-managed `@expo/vector-icons` dependency for icon rendering.
412
456
 
413
457
  ## Package Checks
414
458
 
@@ -43,7 +43,7 @@ declare function AccordionItem({ children, value, style: styleOverride, ...props
43
43
  * Clickable header that expands/collapses the content
44
44
  * Includes animated chevron icon
45
45
  */
46
- declare function AccordionTrigger({ children, style: styleOverride, ...props }: AccordionPrimitive.TriggerProps & {
46
+ declare function AccordionTrigger({ children, style: styleOverride, disabled, ...props }: AccordionPrimitive.TriggerProps & {
47
47
  children?: React.ReactNode;
48
48
  } & React.RefAttributes<AccordionPrimitive.TriggerRef>): import("react").JSX.Element;
49
49
  /**
@@ -5,6 +5,7 @@ import { Icon } from "./Icon.js";
5
5
  import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
6
6
  import { useTheme } from "../hooks/useTheme.js";
7
7
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
8
+ import { useScalePress } from "../hooks/useScalePress.js";
8
9
  import { spacing } from "../constants/spacing.js";
9
10
  import * as AccordionPrimitive from "@rn-primitives/accordion";
10
11
  function normalizeSingleValue(value) {
@@ -96,11 +97,16 @@ const Trigger = Platform.OS === "web" ? View : Pressable;
96
97
  * Clickable header that expands/collapses the content
97
98
  * Includes animated chevron icon
98
99
  */
99
- function AccordionTrigger({ children, style: styleOverride, ...props }) {
100
+ function AccordionTrigger({ children, style: styleOverride, disabled, ...props }) {
100
101
  const { theme } = useTheme();
101
102
  const reduceMotion = useReducedMotion();
102
103
  const { isExpanded } = AccordionPrimitive.useItemContext();
103
104
  const rotation = useRef(new Animated.Value(isExpanded ? 1 : 0)).current;
105
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
106
+ disabled: !!disabled,
107
+ scaleTo: 0.97,
108
+ haptic: false,
109
+ });
104
110
  useEffect(() => {
105
111
  const target = isExpanded ? 1 : 0;
106
112
  Animated.timing(rotation, {
@@ -119,24 +125,24 @@ function AccordionTrigger({ children, style: styleOverride, ...props }) {
119
125
  },
120
126
  ],
121
127
  };
122
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(AccordionPrimitive.Header, { children: _jsx(AccordionPrimitive.Trigger, { ...props, asChild: true, children: _jsxs(Trigger, { style: [
123
- {
124
- flexDirection: "row",
125
- alignItems: "center",
126
- justifyContent: "space-between",
127
- gap: spacing.md,
128
- borderRadius: spacing.radiusMd,
129
- paddingVertical: spacing.md,
130
- ...(Platform.OS === "web" && {
131
- cursor: "pointer",
132
- userSelect: "none",
133
- }),
134
- },
135
- // Spread array styles from primitives to prevent nested arrays on web
136
- ...(styleOverride && typeof styleOverride !== "function"
137
- ? (Array.isArray(styleOverride) ? styleOverride : [styleOverride])
138
- : []),
139
- ], children: [_jsx(_Fragment, { children: children }), _jsx(Animated.View, { style: chevronStyle, children: _jsx(Icon, { name: "chevron-down", size: 16, color: theme.colors.textDim, decorative: true }) })] }) }) }) }) }));
128
+ return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(AccordionPrimitive.Header, { children: _jsx(Animated.View, { style: scaleStyle, children: _jsx(AccordionPrimitive.Trigger, { ...props, disabled: disabled, asChild: true, children: _jsxs(Trigger, { onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, style: [
129
+ {
130
+ flexDirection: "row",
131
+ alignItems: "center",
132
+ justifyContent: "space-between",
133
+ gap: spacing.md,
134
+ borderRadius: spacing.radiusMd,
135
+ paddingVertical: spacing.md,
136
+ ...(Platform.OS === "web" && {
137
+ cursor: "pointer",
138
+ userSelect: "none",
139
+ }),
140
+ },
141
+ // Spread array styles from primitives to prevent nested arrays on web
142
+ ...(styleOverride && typeof styleOverride !== "function"
143
+ ? (Array.isArray(styleOverride) ? styleOverride : [styleOverride])
144
+ : []),
145
+ ], children: [_jsx(_Fragment, { children: children }), _jsx(Animated.View, { style: chevronStyle, children: _jsx(Icon, { name: "chevron-down", size: 16, color: theme.colors.textDim, decorative: true }) })] }) }) }) }) }) }));
140
146
  }
141
147
  /**
142
148
  * Accordion Content Component
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import React, { useMemo } from "react";
2
+ import React from "react";
3
3
  import { View, StyleSheet } from "react-native";
4
4
  import { useTheme } from "../hooks/useTheme.js";
5
5
  import { spacing } from "../constants/spacing.js";
6
6
  import { StyledText } from "./StyledText.js";
7
+ import { createThemedStyles } from "../lib/themedStyles.js";
7
8
  /**
8
9
  * Badge Component
9
10
  *
@@ -19,7 +20,7 @@ import { StyledText } from "./StyledText.js";
19
20
  */
20
21
  function Badge({ children, text, variant = "default", style: styleOverride }) {
21
22
  const { theme } = useTheme();
22
- const styles = useMemo(() => createStyles(theme), [theme]);
23
+ const styles = themedStyles(theme);
23
24
  const badgeContent = text ?? children;
24
25
  const textStyle = [
25
26
  styles.text,
@@ -30,9 +31,13 @@ function Badge({ children, text, variant = "default", style: styleOverride }) {
30
31
  ];
31
32
  const normalizedChildren = React.Children.toArray(badgeContent);
32
33
  const hasOnlyTextChildren = normalizedChildren.every((child) => typeof child === "string" || typeof child === "number");
33
- const content = hasOnlyTextChildren ? (_jsx(StyledText, { selectable: false, style: textStyle, children: normalizedChildren.join("") })) : (React.Children.map(badgeContent, (child) => {
34
+ // size="sm" + fontWeight="medium" match this badge's fontSize/lineHeight/weight
35
+ // (12/18/500) via the shared StyledText scale — including its "sm" letter
36
+ // spacing and correct native weight-file resolution — instead of a hand-rolled
37
+ // fontSize/fontWeight pair.
38
+ const content = hasOnlyTextChildren ? (_jsx(StyledText, { selectable: false, size: "sm", fontWeight: "medium", style: textStyle, children: normalizedChildren.join("") })) : (React.Children.map(badgeContent, (child) => {
34
39
  if (typeof child === "string" || typeof child === "number") {
35
- return _jsx(StyledText, { selectable: false, style: textStyle, children: child });
40
+ return _jsx(StyledText, { selectable: false, size: "sm", fontWeight: "medium", style: textStyle, children: child });
36
41
  }
37
42
  return child;
38
43
  }));
@@ -67,10 +72,8 @@ const createStyles = (theme) => StyleSheet.create({
67
72
  backgroundColor: theme.colors.destructive,
68
73
  },
69
74
  text: {
70
- fontSize: 12,
71
- fontWeight: "500",
72
- lineHeight: 18,
73
75
  userSelect: "none",
74
76
  },
75
77
  });
78
+ const themedStyles = createThemedStyles(createStyles);
76
79
  export { Badge };
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
3
- import { View, Pressable, StyleSheet, ScrollView, Platform, useWindowDimensions, } from "react-native";
3
+ import { View, Pressable, StyleSheet, ScrollView, Platform, Animated, useWindowDimensions, } from "react-native";
4
4
  import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
5
5
  import { KeyboardController } from "react-native-keyboard-controller";
6
6
  import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
7
7
  import { useTheme } from "../hooks/useTheme.js";
8
8
  import { spacing } from "../constants/spacing.js";
9
+ import { useScalePress } from "../hooks/useScalePress.js";
9
10
  import { TextColorContext, TextClassContext } from "./StyledText.context";
10
11
  import { Icon } from "./Icon.js";
11
12
  import { dismissKeyboardFocusedInput, hasKeyboardFocusedInput, subscribeKeyboardFocus, } from "./keyboardFocusRegistry.js";
@@ -140,24 +141,25 @@ function useShowClose() {
140
141
  function SheetCloseButton({ style }) {
141
142
  const { onOpenChange } = useBottomSheetContext();
142
143
  const { theme } = useTheme();
143
- return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", onPress: () => onOpenChange(false), hitSlop: spacing.sm, style: ({ pressed }) => [
144
- {
145
- width: spacing.xl,
146
- height: spacing.xl,
147
- borderRadius: spacing.xl / 2,
148
- alignItems: "center",
149
- justifyContent: "center",
150
- // Higher-contrast than `muted`: a solid `secondary` fill with a
151
- // bordered edge so the control reads clearly against the card, and a
152
- // full-strength `foreground` glyph instead of the faint muted one.
153
- backgroundColor: theme.colors.secondary,
154
- borderWidth: 1,
155
- borderColor: theme.colors.border,
156
- opacity: pressed ? 0.7 : 1,
157
- },
158
- Platform.OS === "web" && { cursor: "pointer" },
159
- style,
160
- ], children: _jsx(Icon, { name: "x", size: 22, color: "text" }) }));
144
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
145
+ scaleTo: 0.96,
146
+ });
147
+ return (_jsx(Animated.View, { style: [style, scaleStyle], children: _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close", onPress: () => onOpenChange(false), onPressIn: pressHandlers.onPressIn, onPressOut: pressHandlers.onPressOut, hitSlop: spacing.sm, style: [
148
+ {
149
+ width: spacing.xl,
150
+ height: spacing.xl,
151
+ borderRadius: spacing.xl / 2,
152
+ alignItems: "center",
153
+ justifyContent: "center",
154
+ // Higher-contrast than `muted`: a solid `secondary` fill with a
155
+ // bordered edge so the control reads clearly against the card, and a
156
+ // full-strength `foreground` glyph instead of the faint muted one.
157
+ backgroundColor: theme.colors.secondary,
158
+ borderWidth: 1,
159
+ borderColor: theme.colors.border,
160
+ },
161
+ Platform.OS === "web" && { cursor: "pointer" },
162
+ ], children: _jsx(Icon, { name: "x", size: 22, color: "text" }) }) }));
161
163
  }
162
164
  // ============================================================================
163
165
  // Keyboard dismiss overlay — tap-away dismissal inside the sheet
@@ -90,7 +90,8 @@ export interface ButtonProps extends PressableProps {
90
90
  */
91
91
  disabledStyle?: StyleProp<ViewStyle>;
92
92
  /**
93
- * Whether to show shadow
93
+ * Whether to show shadow.
94
+ * @default true for the `default` preset, `false` for every other preset.
94
95
  */
95
96
  withShadow?: boolean;
96
97
  /**
@@ -1,5 +1,5 @@
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, useState } from "react";
3
3
  import { Pressable, StyleSheet, View, Platform, ActivityIndicator, Animated, } from "react-native";
4
4
  import { spacing } from "../constants/spacing.js";
5
5
  import { StyledText } from "./StyledText.js";
@@ -9,6 +9,7 @@ import { fontFamilies } from "../constants/fonts.js";
9
9
  import { palette } from "../constants/colors.js";
10
10
  import { useTheme } from "../hooks/useTheme.js";
11
11
  import { useScalePress } from "../hooks/useScalePress.js";
12
+ import { createThemedStyles } from "../lib/themedStyles.js";
12
13
  const SIZE_CONFIGS = {
13
14
  sm: {
14
15
  paddingVertical: spacing.xxs,
@@ -70,11 +71,15 @@ const getNativeHitSlop = (sizeConfig) => Math.ceil(Math.max(0, spacing.touchTarg
70
71
  * ```
71
72
  */
72
73
  function ButtonRoot(props) {
73
- const { tx, text, txOptions, style: styleOverride, pressedStyle: pressedStyleOverride, textStyle: textStyleOverride, pressedTextStyle: pressedTextStyleOverride, disabledTextStyle: disabledTextStyleOverride, children, RightAccessory, LeftAccessory, disabled, disabledStyle: disabledStyleOverride, withShadow = false, preset = "default", size = "md", loading = false, fullWidth = false, onFocus, onBlur, onPressIn, onPressOut, ...rest } = props;
74
+ 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;
74
75
  const { theme, getContrastingColor, getFocusRingStyle, getShadowStyle } = useTheme();
75
- const styles = useMemo(() => createStyles(theme, size), [theme, size]);
76
- const shadowStyle = getShadowStyle("base");
76
+ const styles = themedStyles(theme)[size];
77
+ const shadowStyle = getShadowStyle("subtle");
77
78
  const sizeConfig = SIZE_CONFIGS[size];
79
+ // Filled (`default`) buttons float by default; ghost/outline/link stay flat
80
+ // (transparent background reads oddly with a shadow), and destructive/secondary
81
+ // keep the old opt-in behavior. Callers can still override via `withShadow`.
82
+ const withShadow = withShadowProp ?? preset === "default";
78
83
  const focusRingStyle = getFocusRingStyle();
79
84
  // Pre-compute background color for contrast calculation
80
85
  // Always flatten to handle both array styles (from Slot) and RegisteredStyle
@@ -278,9 +283,15 @@ const createStyles = (theme, size) => {
278
283
  width: "100%",
279
284
  },
280
285
  text: {
281
- fontFamily: fontFamilies.sansSerif.regular,
286
+ // Button labels render at medium weight. On native, the family itself
287
+ // carries the weight (a real static Inter_500Medium file) — pairing it
288
+ // with a numeric fontWeight would faux-bold on top of that file. Web
289
+ // shares one "Inter" family across weights, so it needs the numeric
290
+ // fontWeight to pick the right @font-face variant. (Same rule as
291
+ // StyledText's getFontFamilyWeight / WEB_FONT_WEIGHTS.)
292
+ fontFamily: fontFamilies.sansSerif.medium,
282
293
  fontSize: sizeConfig.fontSize,
283
- fontWeight: "500",
294
+ ...(Platform.OS === "web" && { fontWeight: "500" }),
284
295
  textAlign: "center",
285
296
  lineHeight: sizeConfig.fontSize * 1.4,
286
297
  flexShrink: 0,
@@ -311,6 +322,11 @@ const createStyles = (theme, size) => {
311
322
  },
312
323
  });
313
324
  };
325
+ const themedStyles = createThemedStyles((theme) => ({
326
+ sm: createStyles(theme, "sm"),
327
+ md: createStyles(theme, "md"),
328
+ lg: createStyles(theme, "lg"),
329
+ }));
314
330
  /**
315
331
  * Button with explicit subcomponents.
316
332
  * - `Button.Text` for label text (inherits control typography)
@@ -5,6 +5,7 @@ import { StyledText } from "./StyledText.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
6
  import { useScalePress } from "../hooks/useScalePress.js";
7
7
  import { spacing } from "../constants/spacing.js";
8
+ import { createThemedStyles } from "../lib/themedStyles.js";
8
9
  /**
9
10
  * Card Component
10
11
  *
@@ -30,15 +31,15 @@ import { spacing } from "../constants/spacing.js";
30
31
  const CardContext = createContext(null);
31
32
  function useCardContext() {
32
33
  const ctx = use(CardContext);
33
- // useTheme must run unconditionally (Rules of Hooks); createCardStyles is a
34
+ // useTheme must run unconditionally (Rules of Hooks); themedStyles is a
34
35
  // plain function, so the `??` keeps the fallback styles lazy.
35
36
  const { theme } = useTheme();
36
37
  // Fallback for standalone usage without a Card parent.
37
- return ctx ?? { theme, styles: createCardStyles(theme) };
38
+ return ctx ?? { theme, styles: themedStyles(theme) };
38
39
  }
39
40
  function Card({ children, style: styleOverride, variant = "default", onPress, disabled }) {
40
41
  const { theme, getShadowStyle } = useTheme();
41
- const styles = createCardStyles(theme);
42
+ const styles = themedStyles(theme);
42
43
  const shadowStyle = getShadowStyle("subtle");
43
44
  const ctx = { theme, styles };
44
45
  const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
@@ -122,4 +123,5 @@ const createCardStyles = (theme) => StyleSheet.create({
122
123
  lineHeight: 20,
123
124
  },
124
125
  });
126
+ const themedStyles = createThemedStyles(createCardStyles);
125
127
  export { Card, CardHeader, CardContent, CardFooter, CardTitle, CardDescription };
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef } from "react";
2
+ import { useCallback, useEffect, useRef, useState } 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";
@@ -7,6 +7,7 @@ import { useTheme } from "../hooks/useTheme.js";
7
7
  import { spacing } from "../constants/spacing.js";
8
8
  import { hapticLight } from "../lib/haptics.js";
9
9
  import { useReducedMotion } from "../hooks/useReduceMotion.js";
10
+ import { useScalePress } from "../hooks/useScalePress.js";
10
11
  import * as CheckboxPrimitive from "@rn-primitives/checkbox";
11
12
  const DEFAULT_HIT_SLOP = 8;
12
13
  const SIZE_CONFIGS = {
@@ -15,9 +16,34 @@ const SIZE_CONFIGS = {
15
16
  lg: { size: 24, iconSize: 20, strokeWidth: 3.5 },
16
17
  };
17
18
  function Checkbox({ size = "md", label, indeterminate = false, error = false, style: styleOverride, labelStyle, required = false, checked, onCheckedChange, disabled, ...props }) {
18
- const { theme, getContrastingColor } = useTheme();
19
+ const { theme, getContrastingColor, getFocusRingStyle } = useTheme();
19
20
  const reduceMotion = useReducedMotion();
20
21
  const sizeConfig = SIZE_CONFIGS[size];
22
+ const focusRingStyle = getFocusRingStyle();
23
+ const [focused, setFocused] = useState(false);
24
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
25
+ disabled: !!disabled,
26
+ scaleTo: 0.92,
27
+ haptic: false,
28
+ });
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
+ };
21
47
  // Simple fast opacity for the checkmark icon
22
48
  const checkOpacity = useRef(new Animated.Value(checked || indeterminate ? 1 : 0)).current;
23
49
  const isVisuallyChecked = !!checked || indeterminate;
@@ -45,22 +71,23 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
45
71
  : getContrastingColor(theme.colors.background, theme.colors.text, theme.colors.textDim);
46
72
  // Flatten style override for web compatibility
47
73
  const flattenedStyle = styleOverride ? StyleSheet.flatten(styleOverride) : undefined;
48
- const checkboxElement = (_jsx(CheckboxPrimitive.Root, { ...props, checked: checked, onCheckedChange: wrappedOnCheckedChange, disabled: disabled, style: {
49
- ...styles.box,
50
- borderColor,
51
- backgroundColor: isVisuallyChecked ? theme.colors.primary : theme.colors.background,
52
- width: sizeConfig.size,
53
- height: sizeConfig.size,
54
- opacity: disabled ? 0.5 : 1,
55
- ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
56
- ...(flattenedStyle || {}),
57
- }, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "checkbox", accessibilityState: {
58
- checked: indeterminate ? "mixed" : checked,
59
- disabled: !!disabled,
60
- }, accessibilityLabel: label, children: _jsx(CheckboxPrimitive.Indicator, { style: {
61
- justifyContent: "center",
62
- alignItems: "center",
63
- }, 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 })) }) }) }));
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,
81
+ ...(Platform.OS === "web" && { cursor: disabled ? "not-allowed" : "pointer" }),
82
+ ...(focused && !disabled ? focusRingStyle : null),
83
+ ...(flattenedStyle || {}),
84
+ }, hitSlop: DEFAULT_HIT_SLOP, accessibilityRole: "checkbox", accessibilityState: {
85
+ checked: indeterminate ? "mixed" : checked,
86
+ 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 })) }) }) }) }));
64
91
  // If no label, return just the checkbox
65
92
  if (!label) {
66
93
  return checkboxElement;
@@ -78,7 +105,10 @@ function Checkbox({ size = "md", label, indeterminate = false, error = false, st
78
105
  }
79
106
  const styles = StyleSheet.create({
80
107
  box: {
81
- borderRadius: spacing.radiusSm,
108
+ // radiusXs (not radiusSm) — at the checkbox's 16-24px sizes, radiusSm
109
+ // post-rebase (8px) reads as over-rounded; radiusXs keeps the same
110
+ // proportion the box had before the radius rebase.
111
+ borderRadius: spacing.radiusXs,
82
112
  borderWidth: 1,
83
113
  justifyContent: "center",
84
114
  alignItems: "center",
@@ -41,7 +41,7 @@ declare function Collapsible({ children, ...props }: CollapsibleProps): React.JS
41
41
  * ```
42
42
  */
43
43
  type CollapsibleTriggerProps = CollapsiblePrimitive.TriggerProps;
44
- declare function CollapsibleTrigger({ style: styleOverride, ...props }: CollapsibleTriggerProps): React.JSX.Element;
44
+ declare function CollapsibleTrigger({ style: styleOverride, disabled, ...props }: CollapsibleTriggerProps): React.JSX.Element;
45
45
  /**
46
46
  * CollapsibleContent Component
47
47
  * The content that expands and collapses
@@ -1,28 +1,34 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Platform, StyleSheet, View } from "react-native";
2
+ import { Animated, Platform, StyleSheet, View } from "react-native";
3
3
  import { TextClassContext, TextSelectabilityContext } from "./StyledText.context";
4
4
  import { spacing } from "../constants/spacing.js";
5
5
  import { useTheme } from "../hooks/useTheme.js";
6
+ import { useScalePress } from "../hooks/useScalePress.js";
6
7
  import * as CollapsiblePrimitive from "@rn-primitives/collapsible";
7
8
  function Collapsible({ children, ...props }) {
8
9
  return (_jsx(CollapsiblePrimitive.Root, { ...props, asChild: Platform.OS !== "web", children: _jsx(View, { children: children }) }));
9
10
  }
10
- function CollapsibleTrigger({ style: styleOverride, ...props }) {
11
+ function CollapsibleTrigger({ style: styleOverride, disabled, ...props }) {
11
12
  const { theme } = useTheme();
12
- return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(TextSelectabilityContext.Provider, { value: false, children: _jsx(CollapsiblePrimitive.Trigger, { ...props, style: {
13
- flexDirection: "row",
14
- alignItems: "center",
15
- justifyContent: "space-between",
16
- paddingVertical: spacing.sm,
17
- ...(Platform.OS === "web" && {
18
- cursor: "pointer",
19
- outlineStyle: "none",
20
- userSelect: "none",
21
- }),
22
- ...(styleOverride && typeof styleOverride !== "function"
23
- ? StyleSheet.flatten(styleOverride)
24
- : {}),
25
- } }) }) }));
13
+ const { animatedStyle: scaleStyle, pressHandlers } = useScalePress({
14
+ disabled: !!disabled,
15
+ scaleTo: 0.97,
16
+ haptic: false,
17
+ });
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
+ flexDirection: "row",
20
+ alignItems: "center",
21
+ justifyContent: "space-between",
22
+ paddingVertical: spacing.sm,
23
+ ...(Platform.OS === "web" && {
24
+ cursor: "pointer",
25
+ outlineStyle: "none",
26
+ userSelect: "none",
27
+ }),
28
+ ...(styleOverride && typeof styleOverride !== "function"
29
+ ? StyleSheet.flatten(styleOverride)
30
+ : {}),
31
+ } }) }) }) }));
26
32
  }
27
33
  function CollapsibleContent({ forceMount, style: styleOverride, children, ...props }) {
28
34
  return (_jsx(TextClassContext.Provider, { value: "", children: _jsx(CollapsiblePrimitive.Content, { ...props, forceMount: forceMount, children: _jsx(View, { style: {