@mrmeg/expo-ui 0.27.1 → 0.29.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 (155) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/LLM_USAGE.md +241 -37
  3. package/README.md +591 -136
  4. package/bin/expo-ui-icons.mjs +509 -0
  5. package/dist/components/Accordion.js +12 -6
  6. package/dist/components/AnimatedView.d.ts +1 -1
  7. package/dist/components/AnimatedView.js +5 -2
  8. package/dist/components/Avatar.d.ts +1 -1
  9. package/dist/components/Avatar.js +2 -2
  10. package/dist/components/Badge.js +5 -1
  11. package/dist/components/BottomSheet.d.ts +29 -1
  12. package/dist/components/BottomSheet.js +107 -30
  13. package/dist/components/Button.d.ts +20 -4
  14. package/dist/components/Button.js +60 -43
  15. package/dist/components/Card.d.ts +1 -1
  16. package/dist/components/Card.js +24 -4
  17. package/dist/components/Carousel.js +2 -2
  18. package/dist/components/Checkbox.js +51 -44
  19. package/dist/components/Collapsible.js +7 -3
  20. package/dist/components/Dialog.js +87 -16
  21. package/dist/components/Drawer.d.ts +1 -1
  22. package/dist/components/Drawer.js +65 -55
  23. package/dist/components/DropdownMenu.js +11 -7
  24. package/dist/components/EmptyState.d.ts +2 -2
  25. package/dist/components/EmptyState.js +5 -2
  26. package/dist/components/Hydrated.d.ts +31 -0
  27. package/dist/components/Hydrated.js +30 -0
  28. package/dist/components/Icon.d.ts +71 -28
  29. package/dist/components/Icon.js +70 -28
  30. package/dist/components/IconGlyph.d.ts +15 -0
  31. package/dist/components/IconGlyph.js +32 -0
  32. package/dist/components/IconGlyph.native.d.ts +10 -0
  33. package/dist/components/IconGlyph.native.js +30 -0
  34. package/dist/components/InputOTP.js +5 -2
  35. package/dist/components/Item.d.ts +103 -4
  36. package/dist/components/Item.js +168 -15
  37. package/dist/components/KeyboardAvoidingView.d.ts +1 -1
  38. package/dist/components/KeyboardAvoidingView.js +4 -1
  39. package/dist/components/Label.js +3 -2
  40. package/dist/components/MaxWidthContainer.js +1 -1
  41. package/dist/components/Notification.js +45 -27
  42. package/dist/components/Popover.d.ts +18 -5
  43. package/dist/components/Popover.js +72 -8
  44. package/dist/components/Progress.js +8 -6
  45. package/dist/components/RadioGroup.js +52 -49
  46. package/dist/components/Screen.d.ts +61 -0
  47. package/dist/components/Screen.js +62 -0
  48. package/dist/components/SectionHeader.d.ts +1 -1
  49. package/dist/components/SegmentedControl.d.ts +12 -4
  50. package/dist/components/SegmentedControl.js +94 -4
  51. package/dist/components/Select.js +21 -26
  52. package/dist/components/Skeleton.js +24 -10
  53. package/dist/components/Slider.d.ts +11 -3
  54. package/dist/components/Slider.js +73 -6
  55. package/dist/components/StatCard.d.ts +4 -2
  56. package/dist/components/StatCard.js +3 -1
  57. package/dist/components/StyledText.d.ts +7 -1
  58. package/dist/components/StyledText.js +5 -3
  59. package/dist/components/Switch.d.ts +1 -1
  60. package/dist/components/Switch.js +20 -30
  61. package/dist/components/Tabs.d.ts +2 -2
  62. package/dist/components/Tabs.js +13 -24
  63. package/dist/components/TextInput.d.ts +1 -1
  64. package/dist/components/TextInput.js +71 -26
  65. package/dist/components/ThemeSelector.d.ts +52 -0
  66. package/dist/components/ThemeSelector.js +90 -0
  67. package/dist/components/Toggle.d.ts +5 -49
  68. package/dist/components/Toggle.js +53 -29
  69. package/dist/components/ToggleGroup.d.ts +4 -8
  70. package/dist/components/ToggleGroup.js +52 -34
  71. package/dist/components/Tooltip.d.ts +1 -1
  72. package/dist/components/Tooltip.js +9 -3
  73. package/dist/components/UIProvider.d.ts +22 -2
  74. package/dist/components/UIProvider.js +15 -3
  75. package/dist/components/bottomSheetDismiss.d.ts +19 -0
  76. package/dist/components/bottomSheetDismiss.js +44 -0
  77. package/dist/components/iconBuiltins.generated.d.ts +143 -0
  78. package/dist/components/iconBuiltins.generated.js +25 -0
  79. package/dist/components/iconTypes.d.ts +37 -0
  80. package/dist/components/iconTypes.js +1 -0
  81. package/dist/components/index.d.ts +47 -44
  82. package/dist/components/index.js +3 -0
  83. package/dist/components/keyboardDismiss.js +12 -4
  84. package/dist/components/nativeTextField.android.js +13 -9
  85. package/dist/constants/colors.d.ts +40 -5
  86. package/dist/constants/colors.js +26 -8
  87. package/dist/constants/fonts.d.ts +41 -2
  88. package/dist/constants/fonts.js +46 -7
  89. package/dist/constants/index.d.ts +5 -4
  90. package/dist/constants/index.js +1 -0
  91. package/dist/constants/interaction.d.ts +18 -0
  92. package/dist/constants/interaction.js +18 -0
  93. package/dist/constants/spacing.d.ts +2 -1
  94. package/dist/constants/spacing.js +2 -1
  95. package/dist/design-system.json +429 -132
  96. package/dist/hooks/index.d.ts +12 -7
  97. package/dist/hooks/index.js +5 -0
  98. package/dist/hooks/useDimensions.d.ts +13 -8
  99. package/dist/hooks/useDimensions.js +123 -48
  100. package/dist/hooks/useFocusVisible.d.ts +30 -0
  101. package/dist/hooks/useFocusVisible.js +45 -0
  102. package/dist/hooks/useFontStyle.d.ts +2 -2
  103. package/dist/hooks/useFontStyle.js +3 -2
  104. package/dist/hooks/useHydrated.d.ts +13 -0
  105. package/dist/hooks/useHydrated.js +19 -0
  106. package/dist/hooks/useNotificationOffset.d.ts +20 -0
  107. package/dist/hooks/useNotificationOffset.js +34 -0
  108. package/dist/hooks/useResources.d.ts +21 -20
  109. package/dist/hooks/useResources.js +72 -68
  110. package/dist/hooks/useScalePress.d.ts +8 -5
  111. package/dist/hooks/useScalePress.js +16 -9
  112. package/dist/hooks/useShape.d.ts +24 -0
  113. package/dist/hooks/useShape.js +24 -0
  114. package/dist/hooks/useStaggeredEntrance.js +9 -7
  115. package/dist/hooks/useTheme.d.ts +3 -3
  116. package/dist/hooks/useTheme.js +177 -66
  117. package/dist/hooks/useWindowInsets.d.ts +13 -0
  118. package/dist/hooks/useWindowInsets.js +23 -0
  119. package/dist/icons/LICENSE.lucide +43 -0
  120. package/dist/icons/lucide.json +2144 -0
  121. package/dist/index.d.ts +5 -5
  122. package/dist/lib/composeRefs.d.ts +8 -0
  123. package/dist/lib/composeRefs.js +20 -0
  124. package/dist/lib/haptics.d.ts +12 -0
  125. package/dist/lib/haptics.js +21 -0
  126. package/dist/lib/index.d.ts +6 -5
  127. package/dist/lib/index.js +1 -0
  128. package/dist/lib/interFonts.d.ts +15 -0
  129. package/dist/lib/interFonts.js +1 -0
  130. package/dist/lib/interFonts.native.d.ts +3 -0
  131. package/dist/lib/interFonts.native.js +20 -0
  132. package/dist/lib/popoverPlacement.d.ts +36 -0
  133. package/dist/lib/popoverPlacement.js +29 -0
  134. package/dist/lib/themedStyles.d.ts +1 -1
  135. package/dist/lib/useAnimatedValue.d.ts +15 -0
  136. package/dist/lib/useAnimatedValue.js +19 -0
  137. package/dist/lib/webHitTarget.d.ts +40 -0
  138. package/dist/lib/webHitTarget.js +37 -0
  139. package/dist/state/feedbackStore.d.ts +24 -0
  140. package/dist/state/feedbackStore.js +16 -0
  141. package/dist/state/globalUIStore.d.ts +27 -0
  142. package/dist/state/globalUIStore.js +23 -0
  143. package/dist/state/index.d.ts +7 -5
  144. package/dist/state/index.js +2 -0
  145. package/dist/state/initialScheme.d.ts +27 -0
  146. package/dist/state/initialScheme.js +29 -0
  147. package/dist/state/notify.d.ts +1 -1
  148. package/dist/state/themeColorScope.d.ts +1 -1
  149. package/dist/state/themeStore.d.ts +69 -16
  150. package/dist/state/themeStore.js +65 -38
  151. package/llms-full.md +59 -10
  152. package/llms.txt +10 -1
  153. package/package.json +79 -4
  154. package/dist/components/iconRegistry.generated.d.ts +0 -148
  155. package/dist/components/iconRegistry.generated.js +0 -296
@@ -1,5 +1,5 @@
1
- import type { ThemeColors } from "../constants/colors";
2
- import type { FontOverrides } from "../constants/fonts";
1
+ import type { ThemeColors } from "../constants/colors.js";
2
+ import type { FontOverrides, SerifPreset } from "../constants/fonts.js";
3
3
  export type ThemePreference = "system" | "light" | "dark";
4
4
  export type ResolvedTheme = "light" | "dark";
5
5
  /**
@@ -27,19 +27,28 @@ export type ColorOverrides = {
27
27
  light?: Partial<ThemeColors>;
28
28
  dark?: Partial<ThemeColors>;
29
29
  };
30
+ /** A radius override for one shape slot. `0` is square corners; `9999` is a pill. */
31
+ export type ShapeSlotOverride = {
32
+ /** Border radius for every component in the slot. A caller `style` still wins. */
33
+ borderRadius?: number;
34
+ };
30
35
  /**
31
- * Shape overrides a host app can inject, grouped per component so future
32
- * shape knobs (card radius, input radius, …) slot in without reshaping the
33
- * API. Every field is optional; omitted fields keep the package default.
36
+ * Shape overrides a host app can inject, grouped per slot. Every field is
37
+ * optional; omitted fields keep the package default. Components read their
38
+ * slot through `useShape(slot)` and layer it after the static radius, before
39
+ * the caller's `style`.
40
+ *
41
+ * | Slot | Components | Package default |
42
+ * |---|---|---|
43
+ * | `button` | Button (every preset) | `spacing.radiusMd` (10) |
44
+ * | `input` | TextInput (not `underlined`), Select trigger, InputOTP cells | `spacing.radiusMd` (10) |
45
+ * | `card` | Card, StatCard, EmptyState (`bordered`), SkeletonCard | `spacing.radiusLg` (14) |
46
+ * | `sheet` | BottomSheet top corners (where the platform lets the sheet draw them: web, Android) | platform |
47
+ * | `badge` | Badge | `spacing.radiusFull` |
48
+ * | `dialog` | Dialog and AlertDialog content | `spacing.radiusLg` (14) |
34
49
  */
35
50
  export type ShapeOverrides = {
36
- button?: {
37
- /**
38
- * Border radius applied to every Button preset. Package default:
39
- * `spacing.radiusMd` (10). Use 9999 for pill buttons. A caller `style`
40
- * still wins over this, as it always has.
41
- */
42
- borderRadius?: number;
51
+ button?: ShapeSlotOverride & {
43
52
  /**
44
53
  * Whether the `default` preset renders its shadow. Package default: true.
45
54
  * Other presets stay flat regardless; the per-instance `withShadow` prop
@@ -47,7 +56,13 @@ export type ShapeOverrides = {
47
56
  */
48
57
  withShadow?: boolean;
49
58
  };
59
+ input?: ShapeSlotOverride;
60
+ card?: ShapeSlotOverride;
61
+ sheet?: ShapeSlotOverride;
62
+ badge?: ShapeSlotOverride;
63
+ dialog?: ShapeSlotOverride;
50
64
  };
65
+ export type ShapeSlot = keyof ShapeOverrides;
51
66
  export type ThemeStore = {
52
67
  userTheme: ThemePreference;
53
68
  systemTheme: ResolvedTheme;
@@ -72,11 +87,19 @@ export type ThemeStore = {
72
87
  */
73
88
  fontOverrides: FontOverrides;
74
89
  /**
75
- * App-injected shape overrides (button radius, default-preset shadow).
76
- * Same contract as the other override slots: empty by default, fully
77
- * backward compatible when unset.
90
+ * App-injected shape overrides (radii per slot, the default Button preset's
91
+ * shadow). Same contract as the other override slots: empty by default,
92
+ * fully backward compatible when unset.
78
93
  */
79
94
  shapeOverrides: ShapeOverrides;
95
+ /**
96
+ * Which serif the `serif` variant resolves to when no `setFonts` serif
97
+ * override exists: `"georgia"` (default, the system face) or
98
+ * `"newsreader"`, set by `useResources({ serif: "newsreader" })` once its
99
+ * files or stylesheet are in place. Read by `resolveFontStyle` through
100
+ * `StyledText` and `useFontStyle`.
101
+ */
102
+ serifPreset: SerifPreset;
80
103
  setTheme: (theme: ThemePreference) => void;
81
104
  setSystemTheme: (theme: ResolvedTheme) => void;
82
105
  /**
@@ -96,13 +119,43 @@ export type ThemeStore = {
96
119
  setFonts: (overrides: FontOverrides) => void;
97
120
  /**
98
121
  * Replace the active shape overrides. Pass `{}` to clear them and fall back
99
- * to the package defaults (button radius 12, default-preset shadow on).
122
+ * to the package defaults (see the slot table on `ShapeOverrides`: button
123
+ * and input radius 10 = `spacing.radiusMd`, card and dialog 14 =
124
+ * `spacing.radiusLg`, badge pill, default-preset shadow on).
100
125
  */
101
126
  setShape: (overrides: ShapeOverrides) => void;
127
+ /** Switch the serif preset. `useResources` calls this; apps rarely need to. */
128
+ setSerifPreset: (preset: SerifPreset) => void;
102
129
  loadTheme: () => void;
103
130
  };
104
131
  export declare function resolveThemePreference(userTheme: ThemePreference, systemTheme: ResolvedTheme): ResolvedTheme;
105
132
  export declare const useThemeStore: import("zustand").UseBoundStore<import("zustand").StoreApi<ThemeStore>>;
106
133
  export declare function syncSystemTheme(): void;
134
+ /**
135
+ * Keep `systemTheme` following the OS color scheme (`prefers-color-scheme` on
136
+ * web, `Appearance` on native). The first holder reads the current scheme and
137
+ * attaches the one listener; later calls share it.
138
+ *
139
+ * Returns this caller's release: once every holder has released, the
140
+ * listener is removed. Calling a release again is a no-op, so an effect
141
+ * cleanup that runs twice can't drop another caller's hold. On native the
142
+ * package holds the listener for the app's lifetime (see the module-load
143
+ * init below), so an app's release never stops OS tracking there.
144
+ */
107
145
  export declare function startSystemThemeListener(): () => void;
146
+ /**
147
+ * Single entry point for host apps to populate the store from the
148
+ * environment: the OS color-scheme listener plus the persisted preference.
149
+ *
150
+ * Web apps call it once, from a top-level `useEffect` (never during render or
151
+ * at module scope — see below), and return its result as the cleanup:
152
+ * `useEffect(() => syncThemeFromEnvironment(), [])`. Safe to call more than
153
+ * once (StrictMode's double effects, two roots): every call shares the one
154
+ * OS listener, re-reads the persisted preference, and returns its own
155
+ * cleanup, which releases only that call's hold on the listener.
156
+ *
157
+ * The listener starts BEFORE `loadTheme()` on purpose: reading the real OS
158
+ * scheme first means a `system` user resolves straight from the boot-default
159
+ * "light" to their actual scheme in one commit.
160
+ */
108
161
  export declare function syncThemeFromEnvironment(): () => void;
@@ -38,6 +38,10 @@ export const useThemeStore = create((set) => ({
38
38
  fontOverrides: {},
39
39
  // And again for shape: package geometry until a host app calls `setShape`.
40
40
  shapeOverrides: {},
41
+ serifPreset: "georgia",
42
+ setSerifPreset: (preset) => {
43
+ set({ serifPreset: preset ?? "georgia" });
44
+ },
41
45
  setColors: (overrides) => {
42
46
  set({ colorOverrides: overrides ?? {} });
43
47
  },
@@ -89,15 +93,15 @@ export const useThemeStore = create((set) => ({
89
93
  set({ hasLoadedTheme: true });
90
94
  }
91
95
  }));
92
- let stopSystemThemeListener = null;
96
+ // The OS color-scheme listener is shared: one subscription however many
97
+ // callers hold it, removed when the last holder releases. Each
98
+ // `startSystemThemeListener()` call takes a hold and gets its own release.
99
+ let systemThemeSubscription = null;
100
+ let systemThemeHolds = 0;
93
101
  export function syncSystemTheme() {
94
102
  useThemeStore.getState().setSystemTheme(getSystemTheme());
95
103
  }
96
- export function startSystemThemeListener() {
97
- if (stopSystemThemeListener) {
98
- return stopSystemThemeListener;
99
- }
100
- syncSystemTheme();
104
+ function subscribeToSystemTheme() {
101
105
  if (Platform.OS === "web" && typeof window !== "undefined" && typeof window.matchMedia === "function") {
102
106
  const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
103
107
  const onChange = () => {
@@ -105,48 +109,71 @@ export function startSystemThemeListener() {
105
109
  };
106
110
  if (typeof mediaQuery.addEventListener === "function") {
107
111
  mediaQuery.addEventListener("change", onChange);
108
- stopSystemThemeListener = () => {
109
- mediaQuery.removeEventListener("change", onChange);
110
- stopSystemThemeListener = null;
111
- };
112
- }
113
- else {
114
- mediaQuery.addListener(onChange);
115
- stopSystemThemeListener = () => {
116
- mediaQuery.removeListener(onChange);
117
- stopSystemThemeListener = null;
118
- };
112
+ return { remove: () => mediaQuery.removeEventListener("change", onChange) };
119
113
  }
120
- return stopSystemThemeListener;
114
+ mediaQuery.addListener(onChange);
115
+ return { remove: () => mediaQuery.removeListener(onChange) };
121
116
  }
122
- const subscription = Appearance.addChangeListener(({ colorScheme }) => {
117
+ return Appearance.addChangeListener(({ colorScheme }) => {
123
118
  useThemeStore.getState().setSystemTheme(colorScheme === "dark" ? "dark" : "light");
124
119
  });
125
- stopSystemThemeListener = () => {
126
- subscription.remove();
127
- stopSystemThemeListener = null;
120
+ }
121
+ /**
122
+ * Keep `systemTheme` following the OS color scheme (`prefers-color-scheme` on
123
+ * web, `Appearance` on native). The first holder reads the current scheme and
124
+ * attaches the one listener; later calls share it.
125
+ *
126
+ * Returns this caller's release: once every holder has released, the
127
+ * listener is removed. Calling a release again is a no-op, so an effect
128
+ * cleanup that runs twice can't drop another caller's hold. On native the
129
+ * package holds the listener for the app's lifetime (see the module-load
130
+ * init below), so an app's release never stops OS tracking there.
131
+ */
132
+ export function startSystemThemeListener() {
133
+ systemThemeHolds += 1;
134
+ if (systemThemeHolds === 1) {
135
+ syncSystemTheme();
136
+ systemThemeSubscription = subscribeToSystemTheme();
137
+ }
138
+ let released = false;
139
+ return () => {
140
+ if (released)
141
+ return;
142
+ released = true;
143
+ systemThemeHolds -= 1;
144
+ if (systemThemeHolds === 0) {
145
+ systemThemeSubscription?.remove();
146
+ systemThemeSubscription = null;
147
+ }
128
148
  };
129
- return stopSystemThemeListener;
130
149
  }
131
- // Single entry point for host apps to populate the store from the
132
- // environment (persisted preference + OS color scheme listener). Safe to
133
- // call multiple times — `startSystemThemeListener` is idempotent — and
134
- // returns the unsubscribe so it can be used directly inside `useEffect`.
135
- //
136
- // The listener starts BEFORE `loadTheme()` on purpose: reading the real OS
137
- // scheme first means a `system` user resolves straight from the boot-default
138
- // "light" to their actual scheme in one commit.
150
+ /**
151
+ * Single entry point for host apps to populate the store from the
152
+ * environment: the OS color-scheme listener plus the persisted preference.
153
+ *
154
+ * Web apps call it once, from a top-level `useEffect` (never during render or
155
+ * at module scope — see below), and return its result as the cleanup:
156
+ * `useEffect(() => syncThemeFromEnvironment(), [])`. Safe to call more than
157
+ * once (StrictMode's double effects, two roots): every call shares the one
158
+ * OS listener, re-reads the persisted preference, and returns its own
159
+ * cleanup, which releases only that call's hold on the listener.
160
+ *
161
+ * The listener starts BEFORE `loadTheme()` on purpose: reading the real OS
162
+ * scheme first means a `system` user resolves straight from the boot-default
163
+ * "light" to their actual scheme in one commit.
164
+ */
139
165
  export function syncThemeFromEnvironment() {
140
- const stop = startSystemThemeListener();
166
+ const release = startSystemThemeListener();
141
167
  useThemeStore.getState().loadTheme();
142
- return stop;
168
+ return release;
143
169
  }
144
170
  // Native can read persistence at module load, so keep the historical
145
- // auto-init behavior there. On web the host app must call
146
- // `syncThemeFromEnvironment()` from a top-level `useEffect`: web bundles are
147
- // also evaluated in Node when `expo export` renders the HTML shell, where
148
- // `window`/`localStorage` don't exist, and reading them during the browser's
149
- // first render would disagree with the markup being hydrated.
171
+ // auto-init behavior there, with a hold on the OS listener that is never
172
+ // released. On web the host app must call `syncThemeFromEnvironment()` from a
173
+ // top-level `useEffect`: web bundles are also evaluated in Node when
174
+ // `expo export` renders the HTML shell, where `window`/`localStorage` don't
175
+ // exist, and reading them during the browser's first render would disagree
176
+ // with the markup being hydrated.
150
177
  if (Platform.OS !== "web") {
151
178
  useThemeStore.getState().loadTheme();
152
179
  startSystemThemeListener();
package/llms-full.md CHANGED
@@ -37,7 +37,8 @@ import { Button, colors, UIProvider, useTheme } from "@mrmeg/expo-ui";
37
37
 
38
38
  Call `useResources()` once near the Expo app root; it resolves `{ loaded, error }`
39
39
  and loads the four static Inter weights (web gets a single Google Fonts Inter
40
- stylesheet). Icons are `lucide-react-native` SVGs, so there is no icon font to load.
40
+ stylesheet). Icons are SVG drawn from glyph data, so there is no icon font to load;
41
+ call `registerIcons(appIcons)` once at module scope in the root layout (see `Icon`).
41
42
 
42
43
  Mount `UIProvider` once near the root. It owns the package `Notification`,
43
44
  `StatusBar`, default `@rn-primitives` portal host, and native keyboard-avoiding
@@ -90,6 +91,52 @@ When the saved theme preference is `system`, the package theme store owns OS
90
91
  color-scheme sync, including web `prefers-color-scheme`. Apps must not add their
91
92
  own `Appearance` or `matchMedia` listeners for package components.
92
93
 
94
+ ## Screen Layout
95
+
96
+ Screens are flat. On a 390 pt phone each nested inset comes out of the content:
97
+ rows boxed in a bordered group inside a padded screen start 33 pt from each edge
98
+ (16 + 1 + 16) with 324 pt left for them; the same rows in an `ItemGroup` start
99
+ 16 pt in with 358 pt.
100
+
101
+ - One horizontal inset per screen: `spacing.screenPadding` (16). Either a
102
+ container pads or its children do, never both. `Item` rows and `ItemGroup`
103
+ titles and footers carry the 16 themselves, so a scroll view of groups gets
104
+ no horizontal padding; text, forms, and buttons between them pad their own
105
+ wrapper.
106
+ - No boxes as layout: no bordered, shadowed, or tinted rounded panel (or `Card`)
107
+ around a section, a form, or a group of rows. Separate sections with a header,
108
+ `spacing.sectionSpacing` (24), or a hairline (`Separator`).
109
+ - Lists and settings are `ItemGroup` + `Item`: full-width rows and touch
110
+ targets, hairlines inset under the title, no card per row, no border around
111
+ the group. Rows are direct children; the group separates them.
112
+ - Forms: fields span the column, grouped under section headers, not cards.
113
+ - Primary content (photos, video, maps, QR codes, charts) sizes to the column
114
+ width, never a fixed size floating in whitespace.
115
+ - Wide screens cap and centre the column (`width: "100%", maxWidth: 640,
116
+ alignSelf: "center"` on the scroll content; 960 for dense grids) instead of
117
+ boxing it.
118
+ - `Card` is for one item in a collection (feed entry, grid tile, carousel slide)
119
+ or a single tappable object; `StatCard` likewise (a scrolling metrics rail, a
120
+ tappable metric). Never nest them.
121
+ - Dialog, sheet, and popover chrome keep their surfaces.
122
+
123
+ ```tsx
124
+ <ScrollView contentContainerStyle={{ width: "100%", maxWidth: 640, alignSelf: "center", paddingVertical: spacing.md, gap: spacing.sectionSpacing }}>
125
+ <ItemGroup title="Account" footer="Signed in as jane@example.com">
126
+ <Item onPress={openProfile}>
127
+ <ItemMedia icon="user" />
128
+ <ItemContent><ItemTitle>Edit profile</ItemTitle></ItemContent>
129
+ <ItemActions><Icon name="chevron-right" size={18} color="mutedForeground" /></ItemActions>
130
+ </Item>
131
+ <Item>
132
+ <ItemMedia icon="bell" />
133
+ <ItemContent><ItemTitle>Notifications</ItemTitle></ItemContent>
134
+ <ItemActions><Switch checked={enabled} onCheckedChange={setEnabled} /></ItemActions>
135
+ </Item>
136
+ </ItemGroup>
137
+ </ScrollView>
138
+ ```
139
+
93
140
  ## Component Catalog
94
141
 
95
142
  Every component is exported from `@mrmeg/expo-ui/components`, and from
@@ -105,7 +152,7 @@ before creating a new app-local primitive.
105
152
  | `Badge` | Short status labels | Prefer over custom pill views. |
106
153
  | `BottomSheet` | Mobile-first modal sheets | Requires root `UIProvider`. Native sheet via `@expo/ui`; `swipeEnabled`, `avoidKeyboard`, `dismissKeyboardOnDrag` are accepted but ignored (the iOS sheet presentation and Android Material3 keep content above the keyboard; never nest a `KeyboardAvoidingView` in a sheet). |
107
154
  | `Button` | Commands and CTAs | Use `preset`, not `variant`; heights are 28/32/40. |
108
- | `Card` | Individual framed content groups | Do not wrap whole page sections in cards. |
155
+ | `Card` | One item in a collection (feed entry, grid tile, carousel slide) or a single tappable object | Not a layout box: never around a section, form, or row group; never nested. Parts pad `spacing.cardPadding`. |
109
156
  | `Carousel` | Small known set of horizontally snapping slides | Renders every child (no virtualization); fractional `itemWidth` measures the viewport until first layout. |
110
157
  | `Checkbox` | Boolean selection in forms or lists | Prefer over custom checkmark controls. |
111
158
  | `Collapsible` | One-off disclosure | Use for advanced settings or helper sections. |
@@ -115,9 +162,10 @@ before creating a new app-local primitive.
115
162
  | `DropdownMenu` | Menus and command lists | Requires root `UIProvider` portal host. |
116
163
  | `EmptyState` | No-data or recoverable error regions | Props: `icon`, `title`, `description`, `actionLabel`, `onAction`, `actionPreset`. |
117
164
  | `ErrorBoundary` | React render error fallback | Use for route or feature boundaries. |
118
- | `Icon` | Lucide or custom icons with theme tokens | `color` takes a theme color name; pass `decorative` to hide from a11y. |
165
+ | `Icon` | Registered Lucide glyphs or custom art with theme tokens | `name` is a builtin or a name in `client/icons.json` (`bunx expo-ui-icons add <name>`); `color` takes a theme color name; an unlabeled icon is hidden from a11y. |
119
166
  | `InputOTP` | Verification code entry | Prefer over manually managed text input groups. |
120
167
  | `Item` | List / settings rows | Applies the row density tokens and a 44px native hit area (40px on web). |
168
+ | `ItemGroup` | A titled group of full-width `Item` rows | Props: `title`, `description`, `footer`, `style`, `testID`. Draws inset hairlines between rows; no border, fill, or horizontal padding, so the parent must not pad either. |
121
169
  | `KeyboardAvoidingView` | Native keyboard-aware roots, composer footers, form-heavy subtrees | `UIProvider` already mounts one root; use directly only for custom subtrees. |
122
170
  | `Label` | Accessible form labels | Two distinct ids: `nativeID` is the label's, `htmlFor` is the input's `nativeID`. Never reuse one id for both. |
123
171
  | `MaxWidthContainer` | Centered responsive width | Use for web and tablet constrained layouts. |
@@ -131,7 +179,7 @@ before creating a new app-local primitive.
131
179
  | `Separator` | Horizontal or vertical dividers | Prefer over border-only spacer views. |
132
180
  | `Skeleton` | Loading placeholders | Use stable dimensions to avoid layout shift. |
133
181
  | `Slider` | Numeric value selection | Backed by `@expo/ui`; prefer over custom pan gesture tracks. |
134
- | `StatCard` | Metric tile | Props: `label`, `value`, `unit`, `change` (`{ value, direction }`), `icon`, `onPress`. |
182
+ | `StatCard` | Metric tile in a collection, or one tappable metric | Props: `label`, `value`, `unit`, `change` (`{ value, direction }`), `icon`, `onPress`. A Card, so the same rule applies. |
135
183
  | `StatusBar` | Theme-aware native status bar | Usually mounted through `UIProvider`. |
136
184
  | `StyledText` | Theme-aware typography | Prefer semantic aliases over raw `Text`. |
137
185
  | `Switch` | Binary settings | Prefer over custom toggles for on/off state. |
@@ -156,9 +204,10 @@ option sets.
156
204
  `Dialog` blocking decisions · `Popover` contextual controls · `Tooltip` short
157
205
  explanations · `DropdownMenu` action lists.
158
206
 
159
- `Card` individual repeated or framed items, never a wrapper around full page
160
- sections · `EmptyState` no-data or recoverable errors · `Skeleton` loading
161
- content with stable layout · `Progress` real or indeterminate progress.
207
+ `ItemGroup` + `Item` lists and settings · `Card` one item in a collection or one
208
+ tappable object, never a wrapper around a section, form, or row group, never
209
+ nested · `EmptyState` no-data or recoverable errors · `Skeleton` loading content
210
+ with stable layout · `Progress` real or indeterminate progress.
162
211
 
163
212
  ## Notifications
164
213
 
@@ -196,6 +245,6 @@ selectors and tests. Use `notify` for imperative triggers in app code.
196
245
  ## Validation
197
246
 
198
247
  Changing package code or shipped docs? Run the gates from the monorepo root:
199
- `ui:typecheck`, `ui:test`, `ui:build`, `ui:pack`, `ui:consumer-smoke`
200
- (`bun run <gate>`). Docs-only changes need at least `ui:pack`, which proves the
201
- new docs land in the npm tarball.
248
+ `typecheck`, `test`, `build`, `pack`, `consumer-smoke` (`bun run pkg ui <gate>`).
249
+ Docs-only changes need at least `bun run pkg ui pack`, which proves the new docs
250
+ land in the npm tarball.
package/llms.txt CHANGED
@@ -30,7 +30,16 @@ gestures and keyboard avoidance; swipeEnabled, avoidKeyboard, and
30
30
  dismissKeyboardOnDrag are accepted but ignored. On iOS the sheet presentation
31
31
  lifts or shrinks the hosted content so Footer and Body end at the keyboard's
32
32
  top (device-verified); Android avoidance is Material3's. Do not nest a
33
- KeyboardAvoidingView inside a sheet.
33
+ KeyboardAvoidingView inside a sheet. Open the next modal from the sheet's
34
+ onDismissed callback (fires once the sheet is fully gone), not from
35
+ onOpenChange(false).
36
+
37
+ Screens are flat: one 16 pt horizontal inset (spacing.screenPadding), either
38
+ on the container or on its children, never both. Lists and settings are
39
+ ItemGroup + Item (full-width rows, inset hairlines), never rows inside a
40
+ bordered, shadowed, or tinted panel. Card is for one item in a collection or one
41
+ tappable object; never nest Cards. Media and charts fill the column width; wide
42
+ screens cap and centre the column. LLM_USAGE.md "Screen Layout" has the rules.
34
43
 
35
44
  Use useTheme(), useStyles(), semantic tokens, StyledText, and package controls.
36
45
  Do not add app-local Appearance or matchMedia listeners for theme sync. On web
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.27.1",
3
+ "version": "0.29.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -29,7 +29,15 @@
29
29
  },
30
30
  "main": "./dist/index.js",
31
31
  "types": "./dist/index.d.ts",
32
+ "bin": {
33
+ "expo-ui-icons": "./bin/expo-ui-icons.mjs"
34
+ },
35
+ "sideEffects": [
36
+ "./src/state/themeStore.ts",
37
+ "./dist/state/themeStore.js"
38
+ ],
32
39
  "files": [
40
+ "bin",
33
41
  "dist",
34
42
  "package.json",
35
43
  "README.md",
@@ -40,42 +48,110 @@
40
48
  ],
41
49
  "exports": {
42
50
  ".": {
51
+ "@mrmeg/source": "./src/index.ts",
43
52
  "types": "./dist/index.d.ts",
44
53
  "default": "./dist/index.js"
45
54
  },
46
55
  "./components": {
56
+ "@mrmeg/source": "./src/components/index.ts",
47
57
  "types": "./dist/components/index.d.ts",
48
58
  "default": "./dist/components/index.js"
49
59
  },
60
+ "./components/bottomSheetDismiss": {
61
+ "@mrmeg/source": "./src/components/bottomSheetDismiss.ts",
62
+ "types": "./dist/components/bottomSheetDismiss.d.ts",
63
+ "default": "./dist/components/bottomSheetDismiss.js"
64
+ },
65
+ "./components/bottomSheetHostSupport": {
66
+ "@mrmeg/source": "./src/components/bottomSheetHostSupport.ts",
67
+ "types": "./dist/components/bottomSheetHostSupport.d.ts",
68
+ "default": "./dist/components/bottomSheetHostSupport.js"
69
+ },
70
+ "./components/iconBuiltins.generated": {
71
+ "@mrmeg/source": "./src/components/iconBuiltins.generated.ts",
72
+ "types": "./dist/components/iconBuiltins.generated.d.ts",
73
+ "default": "./dist/components/iconBuiltins.generated.js"
74
+ },
75
+ "./components/iconTypes": {
76
+ "@mrmeg/source": "./src/components/iconTypes.ts",
77
+ "types": "./dist/components/iconTypes.d.ts",
78
+ "default": "./dist/components/iconTypes.js"
79
+ },
80
+ "./components/index": {
81
+ "@mrmeg/source": "./src/components/index.ts",
82
+ "types": "./dist/components/index.d.ts",
83
+ "default": "./dist/components/index.js"
84
+ },
85
+ "./components/keyboardController": {
86
+ "@mrmeg/source": {
87
+ "react-native": "./src/components/keyboardController.native.ts",
88
+ "default": "./src/components/keyboardController.ts"
89
+ },
90
+ "types": "./dist/components/keyboardController.d.ts",
91
+ "default": "./dist/components/keyboardController.js"
92
+ },
93
+ "./components/keyboardDismiss": {
94
+ "@mrmeg/source": "./src/components/keyboardDismiss.ts",
95
+ "types": "./dist/components/keyboardDismiss.d.ts",
96
+ "default": "./dist/components/keyboardDismiss.js"
97
+ },
98
+ "./components/keyboardFocusRegistry": {
99
+ "@mrmeg/source": "./src/components/keyboardFocusRegistry.ts",
100
+ "types": "./dist/components/keyboardFocusRegistry.d.ts",
101
+ "default": "./dist/components/keyboardFocusRegistry.js"
102
+ },
103
+ "./components/StyledText.context": {
104
+ "@mrmeg/source": "./src/components/StyledText.context.ts",
105
+ "types": "./dist/components/StyledText.context.d.ts",
106
+ "default": "./dist/components/StyledText.context.js"
107
+ },
50
108
  "./components/*": {
109
+ "@mrmeg/source": "./src/components/*.tsx",
51
110
  "types": "./dist/components/*.d.ts",
52
111
  "default": "./dist/components/*.js"
53
112
  },
54
113
  "./constants": {
114
+ "@mrmeg/source": "./src/constants/index.ts",
55
115
  "types": "./dist/constants/index.d.ts",
56
116
  "default": "./dist/constants/index.js"
57
117
  },
58
118
  "./constants/*": {
119
+ "@mrmeg/source": "./src/constants/*.ts",
59
120
  "types": "./dist/constants/*.d.ts",
60
121
  "default": "./dist/constants/*.js"
61
122
  },
62
123
  "./hooks": {
124
+ "@mrmeg/source": "./src/hooks/index.ts",
63
125
  "types": "./dist/hooks/index.d.ts",
64
126
  "default": "./dist/hooks/index.js"
65
127
  },
128
+ "./hooks/useReduceMotion": {
129
+ "@mrmeg/source": "./src/hooks/useReduceMotion.tsx",
130
+ "types": "./dist/hooks/useReduceMotion.d.ts",
131
+ "default": "./dist/hooks/useReduceMotion.js"
132
+ },
66
133
  "./hooks/*": {
134
+ "@mrmeg/source": "./src/hooks/*.ts",
67
135
  "types": "./dist/hooks/*.d.ts",
68
136
  "default": "./dist/hooks/*.js"
69
137
  },
70
138
  "./state": {
139
+ "@mrmeg/source": "./src/state/index.ts",
71
140
  "types": "./dist/state/index.d.ts",
72
141
  "default": "./dist/state/index.js"
73
142
  },
143
+ "./state/themeColorScope": {
144
+ "@mrmeg/source": "./src/state/themeColorScope.tsx",
145
+ "types": "./dist/state/themeColorScope.d.ts",
146
+ "default": "./dist/state/themeColorScope.js"
147
+ },
74
148
  "./state/*": {
149
+ "@mrmeg/source": "./src/state/*.ts",
75
150
  "types": "./dist/state/*.d.ts",
76
151
  "default": "./dist/state/*.js"
77
152
  },
78
153
  "./lib": {
154
+ "@mrmeg/source": "./src/lib/index.ts",
79
155
  "types": "./dist/lib/index.d.ts",
80
156
  "default": "./dist/lib/index.js"
81
157
  },
@@ -86,7 +162,7 @@
86
162
  "scripts": {
87
163
  "typecheck": "tsc --noEmit -p tsconfig.json",
88
164
  "test": "jest --config ../../jest.config.js packages/ui/src --runInBand --watchman=false && bun run check:forbidden-imports",
89
- "build": "node ../../scripts/build-icon-registry.mjs && rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-package-esm.mjs ui && node ../../scripts/build-design-system-manifest.mjs ui && bun run check:forbidden-imports",
165
+ "build": "node ../../scripts/build-icon-registry.mjs && rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/icons && cp src/icons/lucide.json src/icons/LICENSE.lucide dist/icons/ && node ../../scripts/fix-package-esm.mjs ui && node ../../scripts/build-design-system-manifest.mjs ui && bun run check:forbidden-imports",
90
166
  "check:forbidden-imports": "node ../../scripts/check-ui-forbidden-imports.mjs",
91
167
  "publish:dry-run": "bun pm pack --dry-run"
92
168
  },
@@ -117,7 +193,6 @@
117
193
  "expo": ">=56.0.0 <59.0.0 || >=58.0.0-0 <59.0.0",
118
194
  "expo-font": ">=56.0.0 <59.0.0 || >=58.0.0-0 <59.0.0",
119
195
  "expo-haptics": ">=56.0.0 <59.0.0 || >=58.0.0-0 <59.0.0",
120
- "lucide-react-native": ">=1.46.0 <2.0.0",
121
196
  "react": ">=19.2.0 <20.0.0",
122
197
  "react-native": ">=0.85.0 <0.89.0 || >=0.88.0-0 <0.89.0",
123
198
  "react-native-gesture-handler": ">=2.30.0 <4.0.0",
@@ -129,7 +204,7 @@
129
204
  "zustand": ">=5.0.0 <6.0.0"
130
205
  },
131
206
  "devDependencies": {
132
- "@types/react": "~19.2.18",
207
+ "@types/react": "~19.3.0",
133
208
  "typescript": "~6.0.3"
134
209
  }
135
210
  }