@multiplatform.one/forms 8.5.0 → 9.0.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 (95) hide show
  1. package/README.md +20 -0
  2. package/dist/cjs/fields/Barcode/BarcodeScanner.native.js +53 -5
  3. package/dist/cjs/fields/Barcode/BarcodeScanner.native.js.map +1 -1
  4. package/dist/cjs/fields/Barcode/expoCameraLoader.cjs +54 -0
  5. package/dist/cjs/fields/Barcode/expoCameraLoader.native.js +58 -0
  6. package/dist/cjs/fields/Barcode/expoCameraLoader.native.js.map +1 -0
  7. package/dist/cjs/fields/ColorPicker/expoLinearGradientLoader.cjs +53 -0
  8. package/dist/cjs/fields/ColorPicker/expoLinearGradientLoader.native.js +57 -0
  9. package/dist/cjs/fields/ColorPicker/expoLinearGradientLoader.native.js.map +1 -0
  10. package/dist/cjs/fields/ColorPicker/gradients.native.js +5 -5
  11. package/dist/cjs/fields/ColorPicker/gradients.native.js.map +1 -1
  12. package/dist/cjs/fields/ColorPicker/nativeLinearGradient.cjs +93 -0
  13. package/dist/cjs/fields/ColorPicker/nativeLinearGradient.native.js +101 -0
  14. package/dist/cjs/fields/ColorPicker/nativeLinearGradient.native.js.map +1 -0
  15. package/dist/cjs/shared/expoNativeModule.cjs +37 -0
  16. package/dist/cjs/shared/expoNativeModule.native.js +40 -0
  17. package/dist/cjs/shared/expoNativeModule.native.js.map +1 -0
  18. package/dist/cjs/shared/i18n.cjs +1 -1
  19. package/dist/cjs/shared/i18n.native.js +1 -1
  20. package/dist/cjs/shared/i18n.native.js.map +1 -1
  21. package/dist/esm/fields/Barcode/BarcodeScanner.native.js +51 -3
  22. package/dist/esm/fields/Barcode/BarcodeScanner.native.js.map +1 -1
  23. package/dist/esm/fields/Barcode/expoCameraLoader.mjs +28 -0
  24. package/dist/esm/fields/Barcode/expoCameraLoader.mjs.map +1 -0
  25. package/dist/esm/fields/Barcode/expoCameraLoader.native.js +29 -0
  26. package/dist/esm/fields/Barcode/expoCameraLoader.native.js.map +1 -0
  27. package/dist/esm/fields/ColorPicker/expoLinearGradientLoader.mjs +27 -0
  28. package/dist/esm/fields/ColorPicker/expoLinearGradientLoader.mjs.map +1 -0
  29. package/dist/esm/fields/ColorPicker/expoLinearGradientLoader.native.js +28 -0
  30. package/dist/esm/fields/ColorPicker/expoLinearGradientLoader.native.js.map +1 -0
  31. package/dist/esm/fields/ColorPicker/gradients.native.js +1 -1
  32. package/dist/esm/fields/ColorPicker/nativeLinearGradient.mjs +65 -0
  33. package/dist/esm/fields/ColorPicker/nativeLinearGradient.mjs.map +1 -0
  34. package/dist/esm/fields/ColorPicker/nativeLinearGradient.native.js +70 -0
  35. package/dist/esm/fields/ColorPicker/nativeLinearGradient.native.js.map +1 -0
  36. package/dist/esm/shared/expoNativeModule.mjs +12 -0
  37. package/dist/esm/shared/expoNativeModule.mjs.map +1 -0
  38. package/dist/esm/shared/expoNativeModule.native.js +12 -0
  39. package/dist/esm/shared/expoNativeModule.native.js.map +1 -0
  40. package/dist/esm/shared/i18n.mjs +1 -1
  41. package/dist/esm/shared/i18n.mjs.map +1 -1
  42. package/dist/esm/shared/i18n.native.js +1 -1
  43. package/dist/esm/shared/i18n.native.js.map +1 -1
  44. package/dist/jsx/fields/Barcode/BarcodeScanner.native.js +53 -5
  45. package/dist/jsx/fields/Barcode/BarcodeScanner.native.js.map +1 -1
  46. package/dist/jsx/fields/Barcode/expoCameraLoader.mjs +28 -0
  47. package/dist/jsx/fields/Barcode/expoCameraLoader.mjs.map +1 -0
  48. package/dist/jsx/fields/Barcode/expoCameraLoader.native.js +58 -0
  49. package/dist/jsx/fields/Barcode/expoCameraLoader.native.js.map +1 -0
  50. package/dist/jsx/fields/ColorPicker/expoLinearGradientLoader.mjs +27 -0
  51. package/dist/jsx/fields/ColorPicker/expoLinearGradientLoader.mjs.map +1 -0
  52. package/dist/jsx/fields/ColorPicker/expoLinearGradientLoader.native.js +57 -0
  53. package/dist/jsx/fields/ColorPicker/expoLinearGradientLoader.native.js.map +1 -0
  54. package/dist/jsx/fields/ColorPicker/gradients.native.js +5 -5
  55. package/dist/jsx/fields/ColorPicker/gradients.native.js.map +1 -1
  56. package/dist/jsx/fields/ColorPicker/nativeLinearGradient.mjs +65 -0
  57. package/dist/jsx/fields/ColorPicker/nativeLinearGradient.mjs.map +1 -0
  58. package/dist/jsx/fields/ColorPicker/nativeLinearGradient.native.js +101 -0
  59. package/dist/jsx/fields/ColorPicker/nativeLinearGradient.native.js.map +1 -0
  60. package/dist/jsx/shared/expoNativeModule.mjs +12 -0
  61. package/dist/jsx/shared/expoNativeModule.mjs.map +1 -0
  62. package/dist/jsx/shared/expoNativeModule.native.js +40 -0
  63. package/dist/jsx/shared/expoNativeModule.native.js.map +1 -0
  64. package/dist/jsx/shared/i18n.mjs +1 -1
  65. package/dist/jsx/shared/i18n.mjs.map +1 -1
  66. package/dist/jsx/shared/i18n.native.js +1 -1
  67. package/dist/jsx/shared/i18n.native.js.map +1 -1
  68. package/metro/index.js +92 -0
  69. package/metro/phosphor-icon-base.js +129 -0
  70. package/package.json +24 -20
  71. package/src/fields/Barcode/BarcodeScanner.native.spec.tsx +59 -0
  72. package/src/fields/Barcode/BarcodeScanner.native.tsx +49 -4
  73. package/src/fields/Barcode/expoCameraLoader.spec.ts +45 -0
  74. package/src/fields/Barcode/expoCameraLoader.ts +46 -0
  75. package/src/fields/ColorPicker/ColorPicker.spec.tsx +18 -16
  76. package/src/fields/ColorPicker/expoLinearGradientLoader.ts +44 -0
  77. package/src/fields/ColorPicker/gradients.native.tsx +1 -1
  78. package/src/fields/ColorPicker/nativeLinearGradient.spec.tsx +109 -0
  79. package/src/fields/ColorPicker/nativeLinearGradient.tsx +84 -0
  80. package/src/metroPreset.spec.ts +132 -0
  81. package/src/shared/expoNativeModule.ts +20 -0
  82. package/src/shared/i18n.ts +7 -10
  83. package/src/shared/i18nPeers.spec.ts +49 -0
  84. package/types/fields/Barcode/BarcodeScanner.native.d.ts +1 -14
  85. package/types/fields/Barcode/BarcodeScanner.native.d.ts.map +1 -1
  86. package/types/fields/Barcode/expoCameraLoader.d.ts +15 -0
  87. package/types/fields/Barcode/expoCameraLoader.d.ts.map +1 -0
  88. package/types/fields/ColorPicker/expoLinearGradientLoader.d.ts +14 -0
  89. package/types/fields/ColorPicker/expoLinearGradientLoader.d.ts.map +1 -0
  90. package/types/fields/ColorPicker/nativeLinearGradient.d.ts +36 -0
  91. package/types/fields/ColorPicker/nativeLinearGradient.d.ts.map +1 -0
  92. package/types/shared/expoNativeModule.d.ts +10 -0
  93. package/types/shared/expoNativeModule.d.ts.map +1 -0
  94. package/types/shared/i18n.d.ts +6 -8
  95. package/types/shared/i18n.d.ts.map +1 -1
@@ -2,7 +2,7 @@ import * as ReactI18next from "react-i18next";
2
2
  const useTranslation = (...args) => {
3
3
  const impl = ReactI18next.useTranslation;
4
4
  if (typeof impl !== "function") {
5
- throw new Error("[@multiplatform.one/forms] react-i18next is not installed, but a component that translates its built-in copy was rendered. Add react-i18next and i18next to your app's dependencies (they are optional peers so apps that never render these components can omit them).");
5
+ throw new Error("[@multiplatform.one/forms] react-i18next is not installed, but a component that translates its built-in copy was rendered. Add react-i18next and i18next to your app's dependencies.");
6
6
  }
7
7
  return impl(...args);
8
8
  };
@@ -1 +1 @@
1
- {"version":3,"names":["ReactI18next","useTranslation","args","impl","Error"],"sources":["../../../src/shared/i18n.ts"],"sourcesContent":[null],"mappings":"AAAA,YAAYA,YAAA,MAAkB;AAYvB,MAAMC,cAAA,GAAkBA,CAAA,GAAIC,IAAA,KAAoB;EACrD,MAAMC,IAAA,GAAQH,YAAA,CACXC,cAAA;EACH,IAAI,OAAOE,IAAA,KAAS,YAAY;IAC9B,MAAM,IAAIC,KAAA,CACR,yQAIF;EACF;EACA,OAAOD,IAAA,CAAK,GAAGD,IAAI;AACrB","ignoreList":[]}
1
+ {"version":3,"names":["ReactI18next","useTranslation","args","impl","Error"],"sources":["../../../src/shared/i18n.ts"],"sourcesContent":[null],"mappings":"AAAA,YAAYA,YAAA,MAAkB;AAUvB,MAAMC,cAAA,GAAkBA,CAAA,GAAIC,IAAA,KAAoB;EACrD,MAAMC,IAAA,GAAQH,YAAA,CACXC,cAAA;EACH,IAAI,OAAOE,IAAA,KAAS,YAAY;IAC9B,MAAM,IAAIC,KAAA,CACR,sLAGF;EACF;EACA,OAAOD,IAAA,CAAK,GAAGD,IAAI;AACrB","ignoreList":[]}
@@ -45,7 +45,7 @@ var useTranslation = function useTranslation2() {
45
45
  }
46
46
  var impl = ReactI18next.useTranslation;
47
47
  if (typeof impl !== "function") {
48
- throw new Error("[@multiplatform.one/forms] react-i18next is not installed, but a component that translates its built-in copy was rendered. Add react-i18next and i18next to your app's dependencies (they are optional peers so apps that never render these components can omit them).");
48
+ throw new Error("[@multiplatform.one/forms] react-i18next is not installed, but a component that translates its built-in copy was rendered. Add react-i18next and i18next to your app's dependencies.");
49
49
  }
50
50
  return impl(...args);
51
51
  };
@@ -1 +1 @@
1
- {"version":3,"names":["__create","Object","create","__defProp","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__getProtoOf","getPrototypeOf","__hasOwnProp","prototype","hasOwnProperty","__export","target","all","name","get","enumerable"],"sources":["../../../src/shared/i18n.ts"],"sourcesContent":[null],"mappings":"AAAA,YAAY;;AAYL,IAAAA,QAAM,GAAAC,MAAA,CAAAC,MAAsB;AACjC,IAAAC,SAAM,GAAQF,MAAA,CAAAG,cACX;AACH,IAAAC,gBAAW,GAASJ,MAAA,CAAAK,wBAAY;AAC9B,IAAAC,iBAAU,GAAAN,MAAA,CAAAO,mBAAA;AAAA,IAAAC,YACR,GAAAR,MAAA,CAAAS,cAAA;AAAA,IAIFC,YAAA,GAAAV,MAAA,CAAAW,SAAA,CAAAC,cAAA;AAAA,IACFC,QAAA,GAAAA,CAAAC,MAAA,EAAAC,GAAA;EACA,SAAOC,IAAK,IAAGD,GAAI,EACrBb,SAAA,CAAAY,MAAA,EAAAE,IAAA;IAAAC,GAAA,EAAAF,GAAA,CAAAC,IAAA;IAAAE,UAAA;EAAA","ignoreList":[]}
1
+ {"version":3,"names":["__create","Object","create","__defProp","defineProperty","__getOwnPropDesc","getOwnPropertyDescriptor","__getOwnPropNames","getOwnPropertyNames","__getProtoOf","getPrototypeOf","__hasOwnProp","prototype","hasOwnProperty","__export","target","all","name","get","enumerable"],"sources":["../../../src/shared/i18n.ts"],"sourcesContent":[null],"mappings":"AAAA,YAAY;;AAUL,IAAAA,QAAM,GAAAC,MAAA,CAAAC,MAAsB;AACjC,IAAAC,SAAM,GAAQF,MAAA,CAAAG,cACX;AACH,IAAAC,gBAAW,GAASJ,MAAA,CAAAK,wBAAY;AAC9B,IAAAC,iBAAU,GAAAN,MAAA,CAAAO,mBAAA;AAAA,IAAAC,YACR,GAAAR,MAAA,CAAAS,cAAA;AAAA,IAGFC,YAAA,GAAAV,MAAA,CAAAW,SAAA,CAAAC,cAAA;AAAA,IACFC,QAAA,GAAAA,CAAAC,MAAA,EAAAC,GAAA;EACA,SAAOC,IAAK,IAAGD,GAAI,EACrBb,SAAA,CAAAY,MAAA,EAAAE,IAAA;IAAAC,GAAA,EAAAF,GAAA,CAAAC,IAAA;IAAAE,UAAA;EAAA","ignoreList":[]}
package/metro/index.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Metro preset for an Expo app that hosts MPO (FZ-10629, FZ-10634).
3
+ *
4
+ * const { withMultiplatformOne } = require("@multiplatform.one/forms/metro");
5
+ * module.exports = withMultiplatformOne(getDefaultConfig(__dirname));
6
+ *
7
+ * Plain CommonJS with no build step, because a metro.config.js is loaded by
8
+ * Node before any transform exists. It touches no `sourceExts`, no
9
+ * `blockList` and no `transformer`. Those belong to the app.
10
+ *
11
+ * It does two things.
12
+ *
13
+ * 1. On iOS and Android, `@phosphor-icons/react`'s IconBase resolves to
14
+ * `phosphor-icon-base.js` beside this file. MPO draws its icons with
15
+ * phosphor, whose IconBase renders DOM `<svg>`; the swap draws the same
16
+ * glyph with react-native-svg and gives it the theme's ink. Web keeps
17
+ * phosphor's own IconBase.
18
+ *
19
+ * 2. With `{ router: "expo-router" }`, a web bundle resolves the `one` that
20
+ * `@multiplatform.one/router` imports to the package's Expo Router binding.
21
+ * The router's web entry is written for One, which builds with Vite. A
22
+ * phone bundle needs none of this: there the app selects the binding with
23
+ * `import "@multiplatform.one/router/expo-router"` and Metro never sees
24
+ * `one`.
25
+ *
26
+ * It CHAINS any `resolveRequest` the app already set, and applying it twice
27
+ * is a no-op, marked with a well-known symbol on the config.
28
+ */
29
+
30
+ "use strict";
31
+
32
+ const path = require("node:path");
33
+
34
+ const metroMarker = Symbol.for("@multiplatform.one/forms/metro");
35
+ const phosphorIconBase = path.join(__dirname, "phosphor-icon-base.js");
36
+ const routerPackage = /[\\/]@multiplatform\.one[\\/]router[\\/]/;
37
+ const routerBindings = { "expo-router": "@multiplatform.one/router/expo-router" };
38
+
39
+ /**
40
+ * @param {{ originModulePath?: string }} context Metro's resolution context
41
+ * @param {string} moduleName the specifier being resolved
42
+ * @param {string | null} platform
43
+ */
44
+ function isPhosphorIconBase(context, moduleName, platform) {
45
+ return (
46
+ platform !== "web" &&
47
+ moduleName.includes("IconBase") &&
48
+ Boolean(context.originModulePath?.includes("@phosphor-icons/react"))
49
+ );
50
+ }
51
+
52
+ /**
53
+ * @template {{ resolver?: { resolveRequest?: Function } }} T
54
+ * @param {T} config the app's Metro config
55
+ * @param {{ router?: "expo-router" }} [options]
56
+ * @returns {T}
57
+ */
58
+ function withMultiplatformOne(config, options = {}) {
59
+ if (config[metroMarker]) return config;
60
+ const binding = options.router === undefined ? undefined : routerBindings[options.router];
61
+ if (options.router !== undefined && !binding) {
62
+ throw new Error(
63
+ `[@multiplatform.one/forms/metro] unknown router "${options.router}". Known: ${Object.keys(routerBindings).join(", ")}`,
64
+ );
65
+ }
66
+ const upstream = config.resolver?.resolveRequest;
67
+ const next = (context, moduleName, platform) =>
68
+ upstream
69
+ ? upstream(context, moduleName, platform)
70
+ : context.resolveRequest(context, moduleName, platform);
71
+ const resolveRequest = (context, moduleName, platform) => {
72
+ if (isPhosphorIconBase(context, moduleName, platform)) {
73
+ return { type: "sourceFile", filePath: phosphorIconBase };
74
+ }
75
+ if (
76
+ binding &&
77
+ platform === "web" &&
78
+ moduleName === "one" &&
79
+ routerPackage.test(context.originModulePath ?? "")
80
+ ) {
81
+ return next(context, binding, platform);
82
+ }
83
+ return next(context, moduleName, platform);
84
+ };
85
+ return {
86
+ ...config,
87
+ resolver: { ...config.resolver, resolveRequest },
88
+ [metroMarker]: true,
89
+ };
90
+ }
91
+
92
+ module.exports = { phosphorIconBase, withMultiplatformOne };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Native-compatible shim for @phosphor-icons/react IconBase.
3
+ * Replaces web SVG elements (svg, path, rect, circle, line, polyline, polygon)
4
+ * with react-native-svg equivalents (Svg, Path, Rect, Circle, Line, Polyline, Polygon).
5
+ */
6
+ const React = require("react");
7
+ const RNSvg = require("react-native-svg");
8
+ const { useTheme } = require("@tamagui/web");
9
+
10
+ const SVG_ELEMENT_MAP = {
11
+ svg: RNSvg.Svg,
12
+ path: RNSvg.Path,
13
+ rect: RNSvg.Rect,
14
+ circle: RNSvg.Circle,
15
+ line: RNSvg.Line,
16
+ polyline: RNSvg.Polyline,
17
+ polygon: RNSvg.Polygon,
18
+ ellipse: RNSvg.Ellipse,
19
+ g: RNSvg.G,
20
+ defs: RNSvg.Defs,
21
+ clipPath: RNSvg.ClipPath,
22
+ mask: RNSvg.Mask,
23
+ use: RNSvg.Use,
24
+ text: RNSvg.Text,
25
+ tspan: RNSvg.TSpan,
26
+ linearGradient: RNSvg.LinearGradient,
27
+ radialGradient: RNSvg.RadialGradient,
28
+ stop: RNSvg.Stop,
29
+ };
30
+
31
+ function transformChildren(children) {
32
+ return React.Children.map(children, (child) => {
33
+ if (!React.isValidElement(child)) return child;
34
+ const type = child.type;
35
+ if (typeof type === "string" && SVG_ELEMENT_MAP[type]) {
36
+ const newProps = { ...child.props };
37
+ if (newProps.children) {
38
+ newProps.children = transformChildren(newProps.children);
39
+ }
40
+ return React.createElement(SVG_ELEMENT_MAP[type], newProps);
41
+ }
42
+ if (child.props && child.props.children) {
43
+ return React.cloneElement(child, {
44
+ ...child.props,
45
+ children: transformChildren(child.props.children),
46
+ });
47
+ }
48
+ return child;
49
+ });
50
+ }
51
+
52
+ function resolveToken(theme, value) {
53
+ if (typeof value !== "string" || value.charAt(0) !== "$") return value;
54
+ const entry = theme && theme[value.slice(1)];
55
+ if (!entry) return undefined;
56
+ return typeof entry.get === "function" ? entry.get() : entry.val || undefined;
57
+ }
58
+
59
+ /*
60
+ * FZ-7680. A glyph handed no colour, `currentColor`, or a token the theme
61
+ * lacks would reach react-native-svg as `currentColor`, which is nil there
62
+ * and paints black on the dark scheme. It takes the theme's ink instead.
63
+ */
64
+ function themeInk(theme) {
65
+ for (const key of ["color", "color12", "color11"]) {
66
+ const value = resolveToken(theme, `$${key}`);
67
+ if (typeof value === "string" && value && value !== "currentColor") return value;
68
+ }
69
+ return "currentColor";
70
+ }
71
+
72
+ function resolvePaint(theme, value) {
73
+ const resolved = resolveToken(theme, value);
74
+ return typeof resolved === "string" && resolved && resolved !== "currentColor"
75
+ ? resolved
76
+ : themeInk(theme);
77
+ }
78
+
79
+ const IconBase = React.forwardRef((props, ref) => {
80
+ const {
81
+ alt: _alt,
82
+ color,
83
+ size,
84
+ weight,
85
+ mirrored: _mirrored,
86
+ children: _children,
87
+ weights,
88
+ ...rest
89
+ } = props;
90
+
91
+ /*
92
+ * MPO-67. Every caller passes a Tamagui token ("$color11",
93
+ * "$accentBackground"); react-native-svg does not know tokens, so the token
94
+ * string fell through to `currentColor`, which on native resolves to nil and
95
+ * paints the glyph OPAQUE BLACK. On the dark scheme that is a glyph you
96
+ * cannot see — the invisible selected tick in Combobox is exactly this.
97
+ * Web never showed it because there the token is a CSS variable the cascade
98
+ * resolves. Resolve the token here, where the value becomes a paint.
99
+ */
100
+ const theme = useTheme();
101
+ const resolvedColor = resolvePaint(theme, color);
102
+ const resolvedSize = size || 24;
103
+ const resolvedWeight = weight || "regular";
104
+
105
+ const weightContent = weights ? weights.get(resolvedWeight) : null;
106
+ const svgChildren = transformChildren(weightContent);
107
+
108
+ return React.createElement(
109
+ RNSvg.Svg,
110
+ {
111
+ ref,
112
+ width: resolvedSize,
113
+ height: resolvedSize,
114
+ fill: resolvedColor,
115
+ viewBox: "0 0 256 256",
116
+ // Decorative glyph parity with web: react-native-svg views are touch
117
+ // responders by default and swallow taps meant for a wrapping Pressable
118
+ // (web <svg> inside <button> passes clicks through).
119
+ pointerEvents: "none",
120
+ ...rest,
121
+ },
122
+ svgChildren,
123
+ );
124
+ });
125
+
126
+ IconBase.displayName = "IconBase";
127
+
128
+ module.exports = IconBase;
129
+ module.exports.default = IconBase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/forms",
3
- "version": "8.5.0",
3
+ "version": "9.0.0",
4
4
  "description": "Cross-platform form components with URL state management",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -27,6 +27,7 @@
27
27
  "source": "src/index.ts",
28
28
  "files": [
29
29
  "dist",
30
+ "metro",
30
31
  "src",
31
32
  "types",
32
33
  "!types/.tsbuildinfo"
@@ -63,17 +64,18 @@
63
64
  "import": "./dist/esm/nativeTwins.mjs",
64
65
  "require": "./dist/cjs/nativeTwins.cjs",
65
66
  "default": "./dist/cjs/nativeTwins.cjs"
66
- }
67
+ },
68
+ "./metro": "./metro/index.js"
67
69
  },
68
70
  "publishConfig": {
69
71
  "access": "public"
70
72
  },
71
73
  "dependencies": {
72
74
  "@floating-ui/react": "^0.27.20",
73
- "@multiplatform.one/platform": "^8.5.0",
74
- "@multiplatform.one/router": "^8.5.0",
75
- "@multiplatform.one/table-primitives": "^8.5.0",
76
- "@multiplatform.one/theme": "^8.5.0",
75
+ "@multiplatform.one/platform": "^9.0.0",
76
+ "@multiplatform.one/router": "^9.0.0",
77
+ "@multiplatform.one/table-primitives": "^9.0.0",
78
+ "@multiplatform.one/theme": "^9.0.0",
77
79
  "@phosphor-icons/react": "^2.1.10",
78
80
  "@tamagui/get-font-sized": "2.7.6",
79
81
  "@tamagui/get-token": "2.7.6",
@@ -84,17 +86,15 @@
84
86
  "@tanstack/form-core": "^1.33.5",
85
87
  "@tanstack/react-form": "^1.33.5",
86
88
  "@tanstack/react-store": "^0.11.0",
87
- "expo-camera": "~55.0.18",
88
- "expo-linear-gradient": "~55.0.13",
89
89
  "html5-qrcode": "^2.3.8",
90
90
  "perfect-freehand": "^1.2.3",
91
91
  "tamagui": "2.7.6"
92
92
  },
93
93
  "devDependencies": {
94
94
  "@expo/ui": "~55.0.17",
95
- "@multiplatform.one/config": "8.5.0",
96
- "@multiplatform.one/storybook": "8.5.0",
97
- "@multiplatform.one/test-utils": "8.5.0",
95
+ "@multiplatform.one/config": "9.0.0",
96
+ "@multiplatform.one/storybook": "9.0.0",
97
+ "@multiplatform.one/test-utils": "9.0.0",
98
98
  "@tamagui/build": "2.7.6",
99
99
  "@tamagui/config": "2.7.6",
100
100
  "@tamagui/vite-plugin": "2.7.6",
@@ -103,8 +103,10 @@
103
103
  "@types/react-dom": "^19.2.3",
104
104
  "@vitejs/plugin-react": "^6.1.2",
105
105
  "@vitest/coverage-v8": "^4.1.12",
106
+ "expo-camera": "~55.0.18",
106
107
  "expo-document-picker": "~55.0.16",
107
108
  "expo-image-picker": "~55.0.23",
109
+ "expo-linear-gradient": "~55.0.13",
108
110
  "expo-maps": "~55.0.21",
109
111
  "happy-dom": "^21.0.3",
110
112
  "i18next": "^25.10.10",
@@ -121,13 +123,15 @@
121
123
  "@react-native-community/datetimepicker": ">=8.0.1",
122
124
  "@react-native-community/slider": ">=4.5.0",
123
125
  "@react-native-segmented-control/segmented-control": ">=2.5.0",
126
+ "expo-camera": "*",
124
127
  "expo-document-picker": "*",
125
128
  "expo-glass-effect": "*",
126
129
  "expo-haptics": "55.0.17",
127
130
  "expo-image-picker": "*",
131
+ "expo-linear-gradient": "*",
128
132
  "expo-maps": "*",
129
133
  "expo-modules-core": "*",
130
- "i18next": "^25.0.0",
134
+ "i18next": ">=25.0.0",
131
135
  "react": "^19.1.0",
132
136
  "react-dom": "^19.1.0",
133
137
  "react-i18next": ">=14",
@@ -148,28 +152,28 @@
148
152
  "@react-native-segmented-control/segmented-control": {
149
153
  "optional": true
150
154
  },
151
- "expo-document-picker": {
155
+ "expo-camera": {
152
156
  "optional": true
153
157
  },
154
- "expo-glass-effect": {
158
+ "expo-document-picker": {
155
159
  "optional": true
156
160
  },
157
- "expo-image-picker": {
161
+ "expo-glass-effect": {
158
162
  "optional": true
159
163
  },
160
- "expo-maps": {
164
+ "expo-haptics": {
161
165
  "optional": true
162
166
  },
163
- "expo-haptics": {
167
+ "expo-image-picker": {
164
168
  "optional": true
165
169
  },
166
- "expo-modules-core": {
170
+ "expo-linear-gradient": {
167
171
  "optional": true
168
172
  },
169
- "i18next": {
173
+ "expo-maps": {
170
174
  "optional": true
171
175
  },
172
- "react-i18next": {
176
+ "expo-modules-core": {
173
177
  "optional": true
174
178
  },
175
179
  "react-native": {
@@ -0,0 +1,59 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import type { KnobProps } from "@multiplatform.one/theme";
3
+ import { fireEvent } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { BarcodeScanner, preflightCamera } from "./BarcodeScanner.native";
6
+
7
+ vi.mock("./expoCameraLoader", () => ({
8
+ loadExpoCamera: vi.fn(() => null),
9
+ }));
10
+
11
+ const knobProps = {
12
+ borderRadius: {},
13
+ inputSurface: {},
14
+ body: {},
15
+ sizeToken: "$4",
16
+ } as unknown as KnobProps;
17
+
18
+ // FZ-10637. An app without expo-camera: the Barcode field fails soft.
19
+ describe("BarcodeScanner (native) without expo-camera", () => {
20
+ it("fails the camera pre-flight with a reason, so the scanner is never presented (LC-19)", async () => {
21
+ await expect(preflightCamera()).resolves.toEqual({
22
+ ok: false,
23
+ reason: "Camera unavailable: this app does not include the camera module.",
24
+ });
25
+ });
26
+
27
+ it("renders the reason and a close action when a caller mounts it anyway", () => {
28
+ const onClose = vi.fn();
29
+ const result = renderWithProviders(
30
+ <BarcodeScanner
31
+ active
32
+ onScan={() => {}}
33
+ onError={() => {}}
34
+ onClose={onClose}
35
+ knobProps={knobProps}
36
+ />,
37
+ );
38
+ expect(result.getByTestId("barcode-scanner-unavailable").textContent).toContain(
39
+ "Camera unavailable: this app does not include the camera module.",
40
+ );
41
+ fireEvent.click(result.getByLabelText("Close scanner"));
42
+ expect(onClose).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("renders nothing while inactive", () => {
46
+ const result = renderWithProviders(
47
+ <BarcodeScanner
48
+ active={false}
49
+ onScan={() => {}}
50
+ onError={() => {}}
51
+ onClose={() => {}}
52
+ knobProps={knobProps}
53
+ />,
54
+ );
55
+ expect(
56
+ result.container.querySelector("[data-testid='barcode-scanner-unavailable']"),
57
+ ).toBeNull();
58
+ });
59
+ });
@@ -1,4 +1,3 @@
1
- import { Camera, CameraView, useCameraPermissions } from "expo-camera";
2
1
  import { useEffect, useRef, useState } from "react";
3
2
  import { StyleSheet } from "react-native";
4
3
  import { Text, View, XStack, YStack } from "tamagui";
@@ -6,6 +5,7 @@ import { Button, IconButton } from "../../Button";
6
5
  import { standardActions } from "../../standardActions";
7
6
  import { formCommonColors, formInputColors } from "../../shared/colorRamps";
8
7
  import { t } from "../../shared/t";
8
+ import { type ExpoCameraModule, loadExpoCamera } from "./expoCameraLoader";
9
9
  import type { BarcodeScannerProps, CameraPreflightResult } from "./scannerTypes";
10
10
  import { resolveNativeBarcodeTypes, scannerViewportHeight } from "./scannerTypes";
11
11
 
@@ -18,8 +18,10 @@ import { resolveNativeBarcodeTypes, scannerViewportHeight } from "./scannerTypes
18
18
  * stable scanner surface, never as a present-fail-dismiss flash.
19
19
  */
20
20
  export async function preflightCamera(): Promise<CameraPreflightResult> {
21
+ const camera = loadExpoCamera();
22
+ if (!camera) return { ok: false, reason: cameraModuleMissing() };
21
23
  try {
22
- const status = await Camera.getCameraPermissionsAsync();
24
+ const status = await camera.Camera.getCameraPermissionsAsync();
23
25
  if (!status.granted && !status.canAskAgain) {
24
26
  return {
25
27
  ok: false,
@@ -47,14 +49,16 @@ export async function preflightCamera(): Promise<CameraPreflightResult> {
47
49
  * Note: the iOS simulator has no camera, so live scanning can only be verified
48
50
  * on a physical device; in the simulator the camera surface renders empty.
49
51
  */
50
- export function BarcodeScanner({
52
+ function CameraScanner({
53
+ camera,
51
54
  active,
52
55
  onScan,
53
56
  onError,
54
57
  onClose,
55
58
  knobProps,
56
59
  formats,
57
- }: BarcodeScannerProps) {
60
+ }: BarcodeScannerProps & { camera: ExpoCameraModule }) {
61
+ const { CameraView, useCameraPermissions } = camera;
58
62
  const barcodeTypes = resolveNativeBarcodeTypes(formats);
59
63
  const viewportPx = scannerViewportHeight(knobProps.sizeToken);
60
64
  const [permission, requestPermission] = useCameraPermissions();
@@ -135,3 +139,44 @@ export function BarcodeScanner({
135
139
  </YStack>
136
140
  );
137
141
  }
142
+
143
+ function cameraModuleMissing(): string {
144
+ return t("Camera unavailable: this app does not include the camera module.");
145
+ }
146
+
147
+ /**
148
+ * The scanner an app without `expo-camera` gets: the reason and a way out,
149
+ * never a crash. `preflightCamera()` already refuses to present the scanner
150
+ * in that app, so this is the guard for a caller that mounts it anyway.
151
+ */
152
+ function CameraUnavailable({
153
+ onClose,
154
+ knobProps,
155
+ }: Pick<BarcodeScannerProps, "onClose" | "knobProps">) {
156
+ return (
157
+ <YStack
158
+ testID="barcode-scanner-unavailable"
159
+ {...knobProps.borderRadius}
160
+ {...knobProps.inputSurface}
161
+ borderColor={formInputColors.border.base}
162
+ padding="$4"
163
+ gap="$3"
164
+ alignItems="center"
165
+ >
166
+ <Text {...knobProps.body} color={formCommonColors.error} textAlign="center">
167
+ {cameraModuleMissing()}
168
+ </Text>
169
+ <IconButton
170
+ icon={standardActions.close.icon}
171
+ onPress={onClose}
172
+ aria-label={t("Close scanner")}
173
+ />
174
+ </YStack>
175
+ );
176
+ }
177
+
178
+ export function BarcodeScanner(props: BarcodeScannerProps) {
179
+ const camera = loadExpoCamera();
180
+ if (!camera) return props.active ? <CameraUnavailable {...props} /> : null;
181
+ return <CameraScanner camera={camera} {...props} />;
182
+ }
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { resolveExpoCamera } from "./expoCameraLoader";
5
+
6
+ const camera = {
7
+ Camera: { getCameraPermissionsAsync: async () => ({ granted: true, canAskAgain: true }) },
8
+ CameraView: () => null,
9
+ useCameraPermissions: () => [null, async () => ({ granted: true })],
10
+ };
11
+
12
+ // FZ-10637. expo-camera is an optional peer the host installs at its own SDK
13
+ // version, so the Barcode field has to survive an app that leaves it out.
14
+ describe("resolveExpoCamera", () => {
15
+ it("returns the module when the package and the native module are both present", () => {
16
+ expect(resolveExpoCamera({ hasNativeModule: () => true, camera: () => camera })).toBe(camera);
17
+ });
18
+
19
+ it("returns null when the app does not install expo-camera", () => {
20
+ expect(resolveExpoCamera({ hasNativeModule: () => true, camera: () => null })).toBeNull();
21
+ const missing = () => {
22
+ throw new Error("Cannot find module 'expo-camera'");
23
+ };
24
+ expect(resolveExpoCamera({ hasNativeModule: () => true, camera: missing })).toBeNull();
25
+ });
26
+
27
+ it("returns null without loading the package when the binary lacks the native module", () => {
28
+ const load = vi.fn(() => camera);
29
+ expect(resolveExpoCamera({ hasNativeModule: () => false, camera: load })).toBeNull();
30
+ expect(load).not.toHaveBeenCalled();
31
+ });
32
+
33
+ it("asks for the ExpoCamera native module", () => {
34
+ const hasNativeModule = vi.fn(() => true);
35
+ resolveExpoCamera({ hasNativeModule, camera: () => camera });
36
+ expect(hasNativeModule).toHaveBeenCalledWith("ExpoCamera");
37
+ });
38
+
39
+ it("loads expo-camera from inside a try block, never a static import, so Metro bundles without it", () => {
40
+ const loader = readFileSync(join(__dirname, "expoCameraLoader.ts"), "utf8");
41
+ expect(loader).toMatch(/try \{\s*return require\("expo-camera"\);/);
42
+ const scanner = readFileSync(join(__dirname, "BarcodeScanner.native.tsx"), "utf8");
43
+ expect(scanner).not.toMatch(/from "expo-camera"/);
44
+ });
45
+ });
@@ -0,0 +1,46 @@
1
+ import { hasExpoNativeModule } from "../../shared/expoNativeModule";
2
+
3
+ export type ExpoCameraModule = typeof import("expo-camera");
4
+
5
+ export interface ExpoCameraSources {
6
+ hasNativeModule(name: string): boolean;
7
+ camera(): unknown;
8
+ }
9
+
10
+ const appSources: ExpoCameraSources = {
11
+ hasNativeModule: hasExpoNativeModule,
12
+ camera: () => {
13
+ try {
14
+ return require("expo-camera");
15
+ } catch {
16
+ return null;
17
+ }
18
+ },
19
+ };
20
+
21
+ /**
22
+ * `expo-camera` is an optional peer: the host app installs it at the version
23
+ * its Expo SDK ships, and an app that never scans a barcode leaves it out.
24
+ * Returns null when the package is not installed or the binary lacks the
25
+ * native module, and the Barcode field then reports the camera as unavailable
26
+ * instead of presenting the scanner (FZ-10637).
27
+ */
28
+ export function resolveExpoCamera(
29
+ sources: ExpoCameraSources = appSources,
30
+ ): ExpoCameraModule | null {
31
+ try {
32
+ if (!sources.hasNativeModule("ExpoCamera")) return null;
33
+ const camera = sources.camera() as Partial<ExpoCameraModule> | null | undefined;
34
+ if (!camera?.CameraView || !camera.Camera || !camera.useCameraPermissions) return null;
35
+ return camera as ExpoCameraModule;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ let cached: ExpoCameraModule | null | undefined;
42
+
43
+ export function loadExpoCamera(): ExpoCameraModule | null {
44
+ if (cached === undefined) cached = resolveExpoCamera();
45
+ return cached;
46
+ }
@@ -28,22 +28,24 @@ vi.mock("@tamagui/lucide-icons-2", () => ({
28
28
  Pipette: () => null,
29
29
  }));
30
30
 
31
- vi.mock("expo-linear-gradient", () => ({
32
- LinearGradient: (props: {
33
- colors?: string[];
34
- locations?: number[];
35
- start?: { x: number; y: number };
36
- end?: { x: number; y: number };
37
- style?: unknown;
38
- testID?: string;
39
- }) =>
40
- createElement("div", {
41
- "data-testid": props.testID ?? "expo-linear-gradient",
42
- "data-colors": JSON.stringify(props.colors ?? []),
43
- "data-locations": JSON.stringify(props.locations ?? null),
44
- "data-start": JSON.stringify(props.start ?? null),
45
- "data-end": JSON.stringify(props.end ?? null),
46
- }),
31
+ vi.mock("./expoLinearGradientLoader", () => ({
32
+ loadExpoLinearGradient: () => ({
33
+ LinearGradient: (props: {
34
+ colors?: string[];
35
+ locations?: number[];
36
+ start?: { x: number; y: number };
37
+ end?: { x: number; y: number };
38
+ style?: unknown;
39
+ testID?: string;
40
+ }) =>
41
+ createElement("div", {
42
+ "data-testid": props.testID ?? "expo-linear-gradient",
43
+ "data-colors": JSON.stringify(props.colors ?? []),
44
+ "data-locations": JSON.stringify(props.locations ?? null),
45
+ "data-start": JSON.stringify(props.start ?? null),
46
+ "data-end": JSON.stringify(props.end ?? null),
47
+ }),
48
+ }),
47
49
  }));
48
50
 
49
51
  async function loadColorPicker() {