@mrmeg/expo-ui 0.25.0 → 0.26.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 (35) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/LLM_USAGE.md +13 -1
  3. package/README.md +89 -18
  4. package/dist/components/Avatar.d.ts +1 -1
  5. package/dist/components/BottomSheet.d.ts +1 -1
  6. package/dist/components/BottomSheet.js +28 -51
  7. package/dist/components/DismissKeyboard.d.ts +8 -6
  8. package/dist/components/DismissKeyboard.js +9 -7
  9. package/dist/components/DropdownMenu.js +1 -1
  10. package/dist/components/Icon.d.ts +24 -22
  11. package/dist/components/Icon.js +26 -3
  12. package/dist/components/Notification.js +3 -3
  13. package/dist/components/Popover.js +1 -1
  14. package/dist/components/Select.js +1 -1
  15. package/dist/components/StatCard.d.ts +1 -1
  16. package/dist/components/TextInput.d.ts +1 -1
  17. package/dist/components/TextInput.js +55 -35
  18. package/dist/components/Tooltip.js +1 -1
  19. package/dist/components/iconRegistry.generated.d.ts +148 -0
  20. package/dist/components/iconRegistry.generated.js +296 -0
  21. package/dist/components/keyboardDismiss.js +126 -55
  22. package/dist/components/keyboardFocusRegistry.d.ts +14 -4
  23. package/dist/components/keyboardFocusRegistry.js +13 -5
  24. package/dist/components/nativeTextField.android.d.ts +3 -0
  25. package/dist/components/nativeTextField.android.js +226 -0
  26. package/dist/components/nativeTextField.d.ts +14 -0
  27. package/dist/components/nativeTextField.js +14 -0
  28. package/dist/constants/spacing.d.ts +2 -2
  29. package/dist/constants/spacing.js +2 -2
  30. package/dist/design-system.json +152 -3
  31. package/dist/hooks/useResources.d.ts +5 -18
  32. package/dist/hooks/useResources.js +5 -43
  33. package/dist/state/themeStore.d.ts +1 -1
  34. package/llms-full.md +3 -3
  35. package/package.json +4 -3
@@ -0,0 +1,226 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Native text field indirection — Android variant.
4
+ *
5
+ * A package-owned port of `@expo/ui`'s universal Android `TextInput`
6
+ * (`@expo/ui` 58.0.2, `src/universal/TextInput/index.android.tsx`, MIT
7
+ * licensed, Copyright (c) 650 Industries, Inc.), built on the public
8
+ * `@expo/ui/jetpack-compose` `BasicTextField`. It keeps the universal field's
9
+ * prop surface (`NativeTextFieldProps` *is* `@expo/ui`'s `TextInputProps`), its
10
+ * `useNativeState` fallback buffer, its ref handle, its slot layout and its
11
+ * modifier order, and differs in exactly two places:
12
+ *
13
+ * 1. **Secure fields declare a password keyboard.** The universal field maps
14
+ * `secureTextEntry` to `visualTransformation="password"` only, so the IME
15
+ * still sees a plain text field: Gboard shows its suggestion strip, learns
16
+ * the password, and `dumpsys input_method` reports `TYPE_CLASS_TEXT` with no
17
+ * password variation. Here a secure field sets `keyboardType` to
18
+ * `'password'` (`textPassword`), or `'numberPassword'` when the requested
19
+ * keyboard is numeric, and defaults `autoCorrectEnabled` to `false` and
20
+ * `capitalization` to `'none'`. Once a mounted field has been secure the
21
+ * password keyboard options stay constant for its lifetime, so the eye
22
+ * toggle (`secureTextEntry` true -> false on the same element) only drops the
23
+ * masking transformation: Compose treats `KeyboardType.Password` with
24
+ * `VisualTransformation.None` as the canonical revealed-password state, and
25
+ * an unchanged `KeyboardOptions` avoids an IME restart, preserving focus,
26
+ * selection and the input connection.
27
+ * 2. **Box style maps only the padding keys the package sends.** The universal
28
+ * field's `transformToModifiers` is not a public export. `TextInput.tsx`
29
+ * hands the field `{ backgroundColor: "transparent", paddingHorizontal,
30
+ * paddingVertical }` and paints the surface (fill, border, radius) on its RN
31
+ * wrapper, so `styleToModifiers` below handles the padding cascade and
32
+ * ignores `backgroundColor`.
33
+ *
34
+ * Only `@expo/ui/jetpack-compose` and `@expo/ui/jetpack-compose/modifiers` are
35
+ * imported, both in the peer's export map since SDK 56.
36
+ */
37
+ import { useImperativeHandle, useRef } from "react";
38
+ import { BasicTextField, Box, Text, useNativeState, } from "@expo/ui/jetpack-compose";
39
+ import { fillMaxWidth, onSizeChanged, padding, paddingAll, semantics, testID as testIDModifier, } from "@expo/ui/jetpack-compose/modifiers";
40
+ // region Prop mapping (ported verbatim from @expo/ui's TextInput/utils.ts)
41
+ function inputModeToKeyboardType(inputMode) {
42
+ if (!inputMode || inputMode === "none")
43
+ return undefined;
44
+ switch (inputMode) {
45
+ case "text":
46
+ return "default";
47
+ case "decimal":
48
+ return "decimal-pad";
49
+ case "tel":
50
+ return "phone-pad";
51
+ case "search":
52
+ return "web-search";
53
+ case "email":
54
+ return "email-address";
55
+ default:
56
+ return inputMode;
57
+ }
58
+ }
59
+ function enterKeyHintToReturnKeyType(hint) {
60
+ if (!hint)
61
+ return undefined;
62
+ if (hint === "enter")
63
+ return "default";
64
+ return hint;
65
+ }
66
+ function resolveEditable(editable, readOnly) {
67
+ if (editable !== undefined)
68
+ return editable;
69
+ if (readOnly === true)
70
+ return false;
71
+ return undefined;
72
+ }
73
+ function mapReturnKeyType(value) {
74
+ if (value === "google" || value === "yahoo")
75
+ return "search";
76
+ if (value === "join" || value === "route" || value === "emergency-call")
77
+ return "default";
78
+ return value;
79
+ }
80
+ function mapKeyboardType(value) {
81
+ switch (value) {
82
+ case "default":
83
+ return "text";
84
+ case "email-address":
85
+ return "email";
86
+ case "numeric":
87
+ return "decimal";
88
+ case "number-pad":
89
+ return "number";
90
+ case "decimal-pad":
91
+ return "decimal";
92
+ case "phone-pad":
93
+ return "phone";
94
+ case "url":
95
+ return "uri";
96
+ case "ascii-capable":
97
+ case "numbers-and-punctuation":
98
+ case "name-phone-pad":
99
+ case "twitter":
100
+ case "web-search":
101
+ case "visible-password":
102
+ return "text";
103
+ default:
104
+ return "text";
105
+ }
106
+ }
107
+ // endregion Prop mapping
108
+ // region Style mapping
109
+ /** Compose modifiers take dp numbers; anything else (percent strings, animated values) is ignored. */
110
+ function dp(value) {
111
+ return typeof value === "number" ? value : undefined;
112
+ }
113
+ /**
114
+ * Maps the box-style padding keys to Compose padding modifiers, with the same
115
+ * cascade as `@expo/ui`'s `transformToModifiers`: a specific side beats the
116
+ * directional shorthand, which beats `padding`. `backgroundColor` is
117
+ * deliberately not mapped — the RN wrapper in `TextInput.tsx` paints the
118
+ * surface.
119
+ */
120
+ function styleToModifiers(style) {
121
+ if (!style)
122
+ return [];
123
+ const all = dp(style.padding) ?? 0;
124
+ const top = dp(style.paddingTop) ?? dp(style.paddingVertical) ?? all;
125
+ const bottom = dp(style.paddingBottom) ?? dp(style.paddingVertical) ?? all;
126
+ const start = dp(style.paddingLeft) ?? dp(style.paddingHorizontal) ?? all;
127
+ const end = dp(style.paddingRight) ?? dp(style.paddingHorizontal) ?? all;
128
+ if (!top && !bottom && !start && !end)
129
+ return [];
130
+ if (top === bottom && bottom === start && start === end)
131
+ return [paddingAll(top)];
132
+ return [padding(start, top, end, bottom)];
133
+ }
134
+ // endregion Style mapping
135
+ /**
136
+ * Picks the password keyboard for a secure field: numeric keyboards become
137
+ * `numberPassword` (a `TYPE_CLASS_NUMBER | TYPE_NUMBER_VARIATION_PASSWORD`
138
+ * field), everything else `password` (`TYPE_TEXT_VARIATION_PASSWORD`).
139
+ */
140
+ function passwordKeyboardType(mapped) {
141
+ return mapped === "number" || mapped === "decimal" || mapped === "phone" ? "numberPassword" : "password";
142
+ }
143
+ export function NativeTextField({ ref, value, onChangeText, placeholder, autoFocus, editable: editableProp, multiline, keyboardType: keyboardTypeProp, autoCapitalize, autoCorrect, returnKeyType: returnKeyTypeProp, onSubmitEditing, onFocus, onBlur, cursorColor, textAlign, readOnly, inputMode, enterKeyHint, defaultValue, numberOfLines: numberOfLinesProp, rows, testID, placeholderTextColor, textStyle, style, secureTextEntry, autoComplete, onContentSizeChange, maxLength, caretHidden, selectionColor, selectionHandleColor, selection, onSelectionChange, selectTextOnFocus, modifiers: userModifiers, }) {
144
+ const editable = resolveEditable(editableProp, readOnly);
145
+ const numberOfLines = numberOfLinesProp ?? rows;
146
+ const keyboardType = keyboardTypeProp ?? inputModeToKeyboardType(inputMode);
147
+ const returnKeyType = returnKeyTypeProp ?? enterKeyHintToReturnKeyType(enterKeyHint);
148
+ const initialFallbackRef = useRef(defaultValue ?? "");
149
+ const fallback = useNativeState(initialFallbackRef.current);
150
+ const state = (value ?? fallback);
151
+ const innerRef = useRef(null);
152
+ const isFocusedRef = useRef(false);
153
+ useImperativeHandle(ref, () => ({
154
+ focus: () => {
155
+ innerRef.current?.focus();
156
+ },
157
+ blur: () => {
158
+ innerRef.current?.blur();
159
+ },
160
+ clear: () => {
161
+ innerRef.current?.clear();
162
+ },
163
+ isFocused: () => isFocusedRef.current,
164
+ setSelection: (start, end) => innerRef.current?.setSelection(start, end) ?? Promise.resolve(),
165
+ }), []);
166
+ const handleFocusChanged = (focused) => {
167
+ isFocusedRef.current = focused;
168
+ if (focused && selectTextOnFocus) {
169
+ innerRef.current?.setSelection(0, state.value.length);
170
+ }
171
+ if (focused)
172
+ onFocus?.();
173
+ else
174
+ onBlur?.();
175
+ };
176
+ // Sticky for the life of the mounted field: revealing a password
177
+ // (`secureTextEntry` -> false on the same element) must keep the password
178
+ // keyboard options unchanged so the IME neither restarts nor starts
179
+ // suggesting/learning the now-visible text. Written during render like the
180
+ // wrapper's own `activeSecureRef`; a fresh mount starts from the prop.
181
+ const everSecureRef = useRef(!!secureTextEntry);
182
+ if (secureTextEntry)
183
+ everSecureRef.current = true;
184
+ const passwordField = everSecureRef.current;
185
+ const mappedKeyboardType = keyboardType ? mapKeyboardType(keyboardType) : undefined;
186
+ const resolvedKeyboardType = passwordField
187
+ ? passwordKeyboardType(mappedKeyboardType)
188
+ : mappedKeyboardType;
189
+ const capitalization = autoCapitalize ?? (passwordField ? "none" : undefined);
190
+ const autoCorrectEnabled = autoCorrect ?? (passwordField ? false : undefined);
191
+ const keyboardOptions = resolvedKeyboardType || capitalization || autoCorrectEnabled !== undefined || returnKeyType
192
+ ? {
193
+ ...(resolvedKeyboardType ? { keyboardType: resolvedKeyboardType } : null),
194
+ ...(capitalization ? { capitalization } : null),
195
+ ...(autoCorrectEnabled !== undefined ? { autoCorrectEnabled } : null),
196
+ ...(returnKeyType ? { imeAction: mapReturnKeyType(returnKeyType) } : null),
197
+ }
198
+ : undefined;
199
+ const keyboardActions = onSubmitEditing
200
+ ? {
201
+ onDone: onSubmitEditing,
202
+ onGo: onSubmitEditing,
203
+ onNext: onSubmitEditing,
204
+ onSearch: onSubmitEditing,
205
+ onSend: onSubmitEditing,
206
+ onPrevious: onSubmitEditing,
207
+ }
208
+ : undefined;
209
+ return (_jsx(BasicTextField, { ref: innerRef, modifiers: [
210
+ ...(userModifiers ?? []),
211
+ ...styleToModifiers(style),
212
+ ...(testID ? [testIDModifier(testID)] : []),
213
+ ...(autoComplete ? [semantics({ contentType: autoComplete })] : []),
214
+ ...(onContentSizeChange ? [onSizeChanged(onContentSizeChange)] : []),
215
+ ], value: state, autoFocus: autoFocus, readOnly: editable === false, singleLine: !multiline, maxLines: multiline && numberOfLines && numberOfLines > 0 ? numberOfLines : undefined, minLines: multiline && numberOfLines && numberOfLines > 0 ? numberOfLines : undefined, cursorColor: caretHidden ? "transparent" : (cursorColor ?? selectionColor), textStyle: textStyle || (textAlign && textAlign !== "auto")
216
+ ? {
217
+ ...textStyle,
218
+ ...(textAlign && textAlign !== "auto" ? { textAlign } : null),
219
+ }
220
+ : undefined, visualTransformation: secureTextEntry ? "password" : undefined, textSelectionColors: selectionColor || selectionHandleColor
221
+ ? {
222
+ handleColor: selectionHandleColor ?? selectionColor,
223
+ backgroundColor: selectionColor,
224
+ }
225
+ : undefined, keyboardOptions: keyboardOptions, keyboardActions: keyboardActions, onValueChange: onChangeText, maxLength: maxLength, onFocusChanged: handleFocusChanged, selection: selection, onSelectionChange: onSelectionChange, children: _jsx(BasicTextField.DecorationBox, { children: _jsxs(Box, { modifiers: [fillMaxWidth()], contentAlignment: textAlign === "center" ? "topCenter" : textAlign === "right" ? "topEnd" : undefined, children: [placeholder != null ? (_jsx(BasicTextField.Placeholder, { children: _jsx(Text, { color: placeholderTextColor, modifiers: [fillMaxWidth()], style: textAlign && textAlign !== "auto" ? { textAlign } : undefined, children: placeholder }) })) : null, _jsx(BasicTextField.InnerTextField, {})] }) }) }));
226
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Native text field indirection — default variant (iOS; also what Jest and
3
+ * TypeScript resolve).
4
+ *
5
+ * Re-exports `@expo/ui`'s universal `TextInput`, which on iOS bridges to
6
+ * SwiftUI's `TextField`/`SecureField` and already reports a password field to
7
+ * the keyboard when `secureTextEntry` is set.
8
+ *
9
+ * Metro resolves `nativeTextField.android.tsx` on Android instead: the package
10
+ * owns the Compose field there so a secure entry can declare a password
11
+ * keyboard type (see that file). Both variants must export the same names with
12
+ * the same types — consumers and the type checker only ever see this one.
13
+ */
14
+ export { TextInput as NativeTextField, type TextInputProps as NativeTextFieldProps, } from "@expo/ui";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Native text field indirection — default variant (iOS; also what Jest and
3
+ * TypeScript resolve).
4
+ *
5
+ * Re-exports `@expo/ui`'s universal `TextInput`, which on iOS bridges to
6
+ * SwiftUI's `TextField`/`SecureField` and already reports a password field to
7
+ * the keyboard when `secureTextEntry` is set.
8
+ *
9
+ * Metro resolves `nativeTextField.android.tsx` on Android instead: the package
10
+ * owns the Compose field there so a secure entry can declare a password
11
+ * keyboard type (see that file). Both variants must export the same names with
12
+ * the same types — consumers and the type checker only ever see this one.
13
+ */
14
+ export { TextInput as NativeTextField, } from "@expo/ui";
@@ -36,7 +36,7 @@ export declare const spacing: {
36
36
  readonly radiusNone: 0;
37
37
  readonly radiusXs: 4;
38
38
  readonly radiusSm: 8;
39
- readonly radiusMd: 12;
39
+ readonly radiusMd: 10;
40
40
  readonly radiusLg: 14;
41
41
  readonly radiusXl: 18;
42
42
  readonly radius2xl: 24;
@@ -47,5 +47,5 @@ export declare const spacing: {
47
47
  readonly iconLg: 32;
48
48
  readonly iconXl: 48;
49
49
  };
50
- export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 12, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
50
+ export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 10, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
51
51
  export declare const space: (multiplier: number) => number;
@@ -38,11 +38,11 @@ export const spacing = {
38
38
  buttonPadding: 10, // Default button padding
39
39
  inputPadding: 10, // Default input padding
40
40
  listItemSpacing: 8, // Space between list items
41
- // Border radius — shadcn-inspired scale (radiusMd = 12px default)
41
+ // Border radius — shadcn-inspired scale (radiusMd = 10px default)
42
42
  radiusNone: 0,
43
43
  radiusXs: 4,
44
44
  radiusSm: 8,
45
- radiusMd: 12,
45
+ radiusMd: 10,
46
46
  radiusLg: 14,
47
47
  radiusXl: 18,
48
48
  radius2xl: 24,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@mrmeg/expo-ui",
4
- "version": "0.25.0",
4
+ "version": "0.26.0",
5
5
  "tokens": {
6
6
  "spacing": {
7
7
  "entries": [
@@ -127,7 +127,7 @@
127
127
  },
128
128
  {
129
129
  "name": "radiusMd",
130
- "value": 12
130
+ "value": 10
131
131
  },
132
132
  {
133
133
  "name": "radiusLg",
@@ -2232,5 +2232,154 @@
2232
2232
  "variantValues": null,
2233
2233
  "sizeValues": null
2234
2234
  }
2235
- ]
2235
+ ],
2236
+ "icons": {
2237
+ "names": [
2238
+ "archive",
2239
+ "arrow-down",
2240
+ "arrow-left",
2241
+ "arrow-right",
2242
+ "arrow-up",
2243
+ "award",
2244
+ "beer",
2245
+ "bell",
2246
+ "bell-off",
2247
+ "bold",
2248
+ "bookmark",
2249
+ "box",
2250
+ "briefcase",
2251
+ "calendar",
2252
+ "calendar-check",
2253
+ "camera",
2254
+ "camera-off",
2255
+ "chart-no-axes-column",
2256
+ "check",
2257
+ "chevron-down",
2258
+ "chevron-left",
2259
+ "chevron-right",
2260
+ "chevron-up",
2261
+ "circle-alert",
2262
+ "circle-check-big",
2263
+ "circle-plus",
2264
+ "circle-question-mark",
2265
+ "circle-stop",
2266
+ "circle-x",
2267
+ "clock",
2268
+ "cloud",
2269
+ "cloud-off",
2270
+ "code",
2271
+ "coffee",
2272
+ "compass",
2273
+ "copy",
2274
+ "credit-card",
2275
+ "crosshair",
2276
+ "database",
2277
+ "dollar-sign",
2278
+ "download",
2279
+ "droplet",
2280
+ "ellipsis-vertical",
2281
+ "external-link",
2282
+ "eye",
2283
+ "eye-off",
2284
+ "face-slightly-smiling",
2285
+ "file",
2286
+ "file-text",
2287
+ "folder",
2288
+ "gift",
2289
+ "git-branch",
2290
+ "git-pull-request",
2291
+ "globe",
2292
+ "grid-3x3",
2293
+ "hash",
2294
+ "heart",
2295
+ "hexagon",
2296
+ "house",
2297
+ "image",
2298
+ "inbox",
2299
+ "info",
2300
+ "italic",
2301
+ "key",
2302
+ "layers",
2303
+ "layout-grid",
2304
+ "link",
2305
+ "link-2",
2306
+ "list",
2307
+ "loader",
2308
+ "lock",
2309
+ "lock-open",
2310
+ "log-in",
2311
+ "log-out",
2312
+ "mail",
2313
+ "map",
2314
+ "map-pin",
2315
+ "maximize",
2316
+ "maximize-2",
2317
+ "menu",
2318
+ "message-circle",
2319
+ "message-square",
2320
+ "mic",
2321
+ "mic-off",
2322
+ "minus",
2323
+ "monitor",
2324
+ "moon",
2325
+ "music",
2326
+ "navigation",
2327
+ "package",
2328
+ "panel-left",
2329
+ "panels-top-left",
2330
+ "party-popper",
2331
+ "pen",
2332
+ "pen-line",
2333
+ "pencil",
2334
+ "percent",
2335
+ "phone",
2336
+ "play",
2337
+ "plus",
2338
+ "printer",
2339
+ "radio",
2340
+ "refresh-cw",
2341
+ "repeat",
2342
+ "rotate-cw",
2343
+ "search",
2344
+ "send",
2345
+ "server",
2346
+ "settings",
2347
+ "share",
2348
+ "share-2",
2349
+ "shield",
2350
+ "shield-off",
2351
+ "shopping-bag",
2352
+ "shopping-cart",
2353
+ "shuffle",
2354
+ "slash",
2355
+ "sliders-horizontal",
2356
+ "sliders-vertical",
2357
+ "smartphone",
2358
+ "square",
2359
+ "square-check-big",
2360
+ "square-pen",
2361
+ "star",
2362
+ "sun",
2363
+ "tag",
2364
+ "terminal",
2365
+ "thumbs-up",
2366
+ "trash",
2367
+ "trending-down",
2368
+ "trending-up",
2369
+ "triangle-alert",
2370
+ "truck",
2371
+ "underline",
2372
+ "upload",
2373
+ "user",
2374
+ "user-check",
2375
+ "user-minus",
2376
+ "user-plus",
2377
+ "users",
2378
+ "video",
2379
+ "wifi-off",
2380
+ "wrench",
2381
+ "x",
2382
+ "zap"
2383
+ ]
2384
+ }
2236
2385
  }
@@ -1,20 +1,3 @@
1
- /**
2
- * Registers the Feather icon font in expo-font's server store during
3
- * export-time rendering (`expo export` renders every route's HTML shell in
4
- * Node, where the module-scope client load above is guarded off).
5
- *
6
- * Must be called from a **render body** (see `useResources` below): expo-font
7
- * scopes its server store per render context, so a module-scope registration
8
- * lands in at most one route's shell. Registering during render means every
9
- * exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
10
- * without it the shell contains an EMPTY icon text node while the client's
11
- * first render (font pre-loaded above) contains the glyph, and React throws
12
- * hydration error #418 on every cold load and regenerates the tree.
13
- *
14
- * No-op on native and on client web (both already covered: native by the
15
- * effect below, client web by the module-scope call above).
16
- */
17
- export declare function ensureIconFontRegistered(): void;
18
1
  interface LoadResourcesResult {
19
2
  loaded: boolean;
20
3
  error: Error | null;
@@ -27,12 +10,16 @@ interface LoadResourcesResult {
27
10
  * font files. Web loads Inter from Google Fonts as a single CSS family;
28
11
  * weight differentiation there comes from a numeric fontWeight instead.
29
12
  *
13
+ * Icons need nothing here: `Icon` renders `lucide-react-native` SVGs, which
14
+ * have no font face to register on any platform, so server-rendered HTML
15
+ * carries the glyph markup and hydrates without a font round trip.
16
+ *
30
17
  * A host app that overrides the sans-serif families via `setFonts` owns
31
18
  * loading its own faces (typically through `expo-font`), so the Inter fetch
32
19
  * is skipped entirely — nothing would reference those files. For the skip to
33
20
  * apply, call `setFonts` before this hook mounts (module scope or ahead of
34
21
  * rendering the root); a later call still re-skins text, it just doesn't
35
- * un-download Inter. The Feather icon font always loads.
22
+ * un-download Inter.
36
23
  */
37
24
  export declare const useResources: () => LoadResourcesResult;
38
25
  export {};
@@ -1,46 +1,8 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import * as Font from "expo-font";
3
- import Feather from "@expo/vector-icons/Feather";
4
3
  import { Inter_400Regular, Inter_500Medium, Inter_600SemiBold, Inter_700Bold, } from "@expo-google-fonts/inter";
5
4
  import { Platform } from "react-native";
6
5
  import { useThemeStore } from "../state/themeStore.js";
7
- // Eager, module-scope load that primes the Feather font before first render:
8
- // on web it synchronously injects the @font-face rule, so icons have their
9
- // glyphs on the first paint; on native it just starts the fetch ahead of the
10
- // effect below (which then no-ops for the already-loaded font).
11
- //
12
- // Guarded on `window` because web bundles are also evaluated in Node when
13
- // `expo export` renders the HTML shell, where there is no document to inject
14
- // into.
15
- if (typeof window !== "undefined") {
16
- void Font.loadAsync(Feather.font);
17
- }
18
- /**
19
- * Registers the Feather icon font in expo-font's server store during
20
- * export-time rendering (`expo export` renders every route's HTML shell in
21
- * Node, where the module-scope client load above is guarded off).
22
- *
23
- * Must be called from a **render body** (see `useResources` below): expo-font
24
- * scopes its server store per render context, so a module-scope registration
25
- * lands in at most one route's shell. Registering during render means every
26
- * exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
27
- * without it the shell contains an EMPTY icon text node while the client's
28
- * first render (font pre-loaded above) contains the glyph, and React throws
29
- * hydration error #418 on every cold load and regenerates the tree.
30
- *
31
- * No-op on native and on client web (both already covered: native by the
32
- * effect below, client web by the module-scope call above).
33
- */
34
- export function ensureIconFontRegistered() {
35
- if (Platform.OS !== "web" || typeof window !== "undefined")
36
- return;
37
- if (Font.isLoaded("feather"))
38
- return;
39
- // Synchronous on server-web (expo-font routes to registerStaticFont). No
40
- // `void`/try-catch: a throw here means we're outside a render scope, which
41
- // must be loud.
42
- Font.loadAsync(Feather.font);
43
- }
44
6
  // The four static Inter weights StyledText's native family keys point at
45
7
  // (see constants/fonts.ts). Native-only: web never renders these family names
46
8
  // ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
@@ -86,17 +48,18 @@ function ensureWebFontStylesheet() {
86
48
  * font files. Web loads Inter from Google Fonts as a single CSS family;
87
49
  * weight differentiation there comes from a numeric fontWeight instead.
88
50
  *
51
+ * Icons need nothing here: `Icon` renders `lucide-react-native` SVGs, which
52
+ * have no font face to register on any platform, so server-rendered HTML
53
+ * carries the glyph markup and hydrates without a font round trip.
54
+ *
89
55
  * A host app that overrides the sans-serif families via `setFonts` owns
90
56
  * loading its own faces (typically through `expo-font`), so the Inter fetch
91
57
  * is skipped entirely — nothing would reference those files. For the skip to
92
58
  * apply, call `setFonts` before this hook mounts (module scope or ahead of
93
59
  * rendering the root); a later call still re-skins text, it just doesn't
94
- * un-download Inter. The Feather icon font always loads.
60
+ * un-download Inter.
95
61
  */
96
62
  export const useResources = () => {
97
- // Export-time renders need the icon font registered per render scope; the
98
- // module-scope call only reaches the client. See ensureIconFontRegistered.
99
- ensureIconFontRegistered();
100
63
  const [loaded, setLoaded] = useState(false);
101
64
  const [error, setError] = useState(null);
102
65
  useEffect(() => {
@@ -107,7 +70,6 @@ export const useResources = () => {
107
70
  async function loadResourcesAndDataAsync() {
108
71
  try {
109
72
  const fontPromise = Promise.all([
110
- Font.loadAsync(Feather.font),
111
73
  sansSerifOverridden ? Promise.resolve() : loadNativeInterFonts(),
112
74
  sansSerifOverridden ? Promise.resolve() : ensureWebFontStylesheet(),
113
75
  ]);
@@ -36,7 +36,7 @@ export type ShapeOverrides = {
36
36
  button?: {
37
37
  /**
38
38
  * Border radius applied to every Button preset. Package default:
39
- * `spacing.radiusMd` (12). Use 9999 for pill buttons. A caller `style`
39
+ * `spacing.radiusMd` (10). Use 9999 for pill buttons. A caller `style`
40
40
  * still wins over this, as it always has.
41
41
  */
42
42
  borderRadius?: number;
package/llms-full.md CHANGED
@@ -36,8 +36,8 @@ import { Button, colors, UIProvider, useTheme } from "@mrmeg/expo-ui";
36
36
  ## App Setup
37
37
 
38
38
  Call `useResources()` once near the Expo app root; it resolves `{ loaded, error }`
39
- and loads the Feather icon font plus the four static Inter weights (web gets a
40
- single Google Fonts Inter stylesheet).
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.
41
41
 
42
42
  Mount `UIProvider` once near the root. It owns the package `Notification`,
43
43
  `StatusBar`, default `@rn-primitives` portal host, and native keyboard-avoiding
@@ -115,7 +115,7 @@ before creating a new app-local primitive.
115
115
  | `DropdownMenu` | Menus and command lists | Requires root `UIProvider` portal host. |
116
116
  | `EmptyState` | No-data or recoverable error regions | Props: `icon`, `title`, `description`, `actionLabel`, `onAction`, `actionPreset`. |
117
117
  | `ErrorBoundary` | React render error fallback | Use for route or feature boundaries. |
118
- | `Icon` | Feather or custom icons with theme tokens | `color` takes a theme color name; pass `decorative` to hide from a11y. |
118
+ | `Icon` | Lucide or custom icons with theme tokens | `color` takes a theme color name; pass `decorative` to hide from a11y. |
119
119
  | `InputOTP` | Verification code entry | Prefer over manually managed text input groups. |
120
120
  | `Item` | List / settings rows | Applies the row density tokens and a 44px native hit area (40px on web). |
121
121
  | `KeyboardAvoidingView` | Native keyboard-aware roots, composer footers, form-heavy subtrees | `UIProvider` already mounts one root; use directly only for custom subtrees. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [
@@ -86,13 +86,12 @@
86
86
  "scripts": {
87
87
  "typecheck": "tsc --noEmit -p tsconfig.json",
88
88
  "test": "jest --config ../../jest.config.js packages/ui/src --runInBand --watchman=false && bun run check:forbidden-imports",
89
- "build": "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",
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",
90
90
  "check:forbidden-imports": "node ../../scripts/check-ui-forbidden-imports.mjs",
91
91
  "publish:dry-run": "bun pm pack --dry-run"
92
92
  },
93
93
  "dependencies": {
94
94
  "@expo-google-fonts/inter": "^0.4.2",
95
- "@expo/vector-icons": ">=15.0.0 <16.0.0",
96
95
  "@rn-primitives/accordion": "~1.5.2",
97
96
  "@rn-primitives/alert-dialog": "~1.5.2",
98
97
  "@rn-primitives/checkbox": "~1.5.2",
@@ -118,12 +117,14 @@
118
117
  "expo": ">=56.0.0 <59.0.0 || >=58.0.0-0 <59.0.0",
119
118
  "expo-font": ">=56.0.0 <59.0.0 || >=58.0.0-0 <59.0.0",
120
119
  "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
121
  "react": ">=19.2.0 <20.0.0",
122
122
  "react-native": ">=0.85.0 <0.89.0 || >=0.88.0-0 <0.89.0",
123
123
  "react-native-gesture-handler": ">=2.30.0 <4.0.0",
124
124
  "react-native-keyboard-controller": ">=1.21.0 <2.0.0",
125
125
  "react-native-safe-area-context": ">=5.6.0 <6.0.0",
126
126
  "react-native-screens": ">=4.23.0 <5.0.0",
127
+ "react-native-svg": ">=15.0.0 <16.0.0",
127
128
  "react-native-web": ">=0.21.0 <0.22.0",
128
129
  "zustand": ">=5.0.0 <6.0.0"
129
130
  },