@terreno/ui 0.13.0 → 0.13.3

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 (54) hide show
  1. package/dist/ConsentFormScreen.js +8 -7
  2. package/dist/ConsentFormScreen.js.map +1 -1
  3. package/dist/PickerSelect.d.ts +22 -10
  4. package/dist/PickerSelect.js +11 -9
  5. package/dist/PickerSelect.js.map +1 -1
  6. package/dist/SelectBadge.js +11 -1
  7. package/dist/SelectBadge.js.map +1 -1
  8. package/dist/SelectField.js +2 -2
  9. package/dist/SelectField.js.map +1 -1
  10. package/dist/SidebarNavigation.native.js.map +1 -1
  11. package/dist/Signature.js +4 -0
  12. package/dist/Signature.js.map +1 -1
  13. package/dist/Signature.native.js +4 -0
  14. package/dist/Signature.native.js.map +1 -1
  15. package/dist/Theme.d.ts +1 -1
  16. package/dist/Theme.js.map +1 -1
  17. package/dist/useConsentForms.d.ts +4 -3
  18. package/dist/useConsentForms.js +26 -4
  19. package/dist/useConsentForms.js.map +1 -1
  20. package/dist/useConsentHistory.d.ts +4 -3
  21. package/dist/useConsentHistory.js +26 -4
  22. package/dist/useConsentHistory.js.map +1 -1
  23. package/dist/useSubmitConsent.d.ts +7 -6
  24. package/dist/useSubmitConsent.js +25 -3
  25. package/dist/useSubmitConsent.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/ConsentFormScreen.test.tsx +47 -0
  28. package/src/ConsentFormScreen.tsx +11 -0
  29. package/src/HeightField.test.tsx +68 -0
  30. package/src/Modal.tsx +2 -2
  31. package/src/PickerSelect.tsx +48 -34
  32. package/src/SelectBadge.tsx +7 -3
  33. package/src/SelectField.tsx +2 -2
  34. package/src/SidebarNavigation.native.tsx +6 -2
  35. package/src/Signature.native.tsx +4 -0
  36. package/src/Signature.test.tsx +10 -0
  37. package/src/Signature.tsx +4 -0
  38. package/src/Theme.tsx +17 -14
  39. package/src/Tooltip.test.tsx +41 -22
  40. package/src/__snapshots__/AddressField.test.tsx.snap +0 -1
  41. package/src/__snapshots__/CustomSelectField.test.tsx.snap +0 -7
  42. package/src/__snapshots__/Field.test.tsx.snap +0 -6
  43. package/src/__snapshots__/PickerSelect.test.tsx.snap +0 -7
  44. package/src/__snapshots__/SelectField.test.tsx.snap +0 -6
  45. package/src/__snapshots__/TerrenoProvider.test.tsx.snap +4 -18
  46. package/src/__snapshots__/TimezonePicker.test.tsx.snap +0 -6
  47. package/src/bunSetup.ts +22 -0
  48. package/src/table/__snapshots__/TableBadge.test.tsx.snap +0 -1
  49. package/src/useConsentForms.test.ts +25 -0
  50. package/src/useConsentForms.ts +32 -7
  51. package/src/useConsentHistory.test.ts +99 -0
  52. package/src/useConsentHistory.ts +31 -6
  53. package/src/useSubmitConsent.test.ts +24 -0
  54. package/src/useSubmitConsent.ts +35 -10
@@ -1,7 +1,22 @@
1
- export const useConsentHistory = (api, baseUrl) => {
2
- var _a;
3
- const base = baseUrl || "";
4
- const enhancedApi = api.injectEndpoints({
1
+ /**
2
+ * Cache the enhanced api per (api, baseUrl). `injectEndpoints` logs a console
3
+ * error in development whenever an endpoint with the same name is re-injected
4
+ * (with `overrideExisting: false`), so calling it on every render of every
5
+ * consumer would flood the console. WeakMap-by-api lets the GC reclaim entries
6
+ * when the api object is unreachable.
7
+ */
8
+ const enhancedApiCache = new WeakMap();
9
+ const getEnhancedApi = (api, base) => {
10
+ let byBase = enhancedApiCache.get(api);
11
+ if (!byBase) {
12
+ byBase = new Map();
13
+ enhancedApiCache.set(api, byBase);
14
+ }
15
+ const cached = byBase.get(base);
16
+ if (cached) {
17
+ return cached;
18
+ }
19
+ const enhanced = api.injectEndpoints({
5
20
  endpoints: (build) => ({
6
21
  getMyConsents: build.query({
7
22
  providesTags: ["MyConsents"],
@@ -10,6 +25,13 @@ export const useConsentHistory = (api, baseUrl) => {
10
25
  }),
11
26
  overrideExisting: false,
12
27
  });
28
+ byBase.set(base, enhanced);
29
+ return enhanced;
30
+ };
31
+ export const useConsentHistory = (api, baseUrl) => {
32
+ var _a;
33
+ const base = baseUrl || "";
34
+ const enhancedApi = getEnhancedApi(api, base);
13
35
  const { data, isLoading, error, refetch } = enhancedApi.useGetMyConsentsQuery();
14
36
  const entries = Array.isArray(data) ? data : ((_a = data === null || data === void 0 ? void 0 : data.data) !== null && _a !== void 0 ? _a : []);
15
37
  return { entries, error, isLoading, refetch };
@@ -1 +1 @@
1
- {"version":3,"file":"useConsentHistory.js","sourceRoot":"","sources":["../src/useConsentHistory.ts"],"names":[],"mappings":"AA8CA,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,GAAsB,EAAE,OAAgB,EAAE,EAAE;;IAC5E,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAE3B,MAAM,WAAW,GAAG,GAAG,CAAC,eAAe,CAAC;QACtC,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACrB,aAAa,EAAE,KAAK,CAAC,KAAK,CAAC;gBACzB,YAAY,EAAE,CAAC,YAAY,CAAC;gBAC5B,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,IAAI,cAAc;aACnC,CAAC;SACH,CAAC;QACF,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IAEH,MAAM,EAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAC,GAAG,WAAW,CAAC,qBAAqB,EAAE,CAAC;IAC9E,MAAM,OAAO,GAA0B,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,IAAI,mCAAI,EAAE,CAAC,CAAC;IAEvF,OAAO,EAAC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAC,CAAC;AAC9C,CAAC,CAAC"}
1
+ {"version":3,"file":"useConsentHistory.js","sourceRoot":"","sources":["../src/useConsentHistory.ts"],"names":[],"mappings":"AAgDA;;;;;;GAMG;AACH,MAAM,gBAAgB,GAAG,IAAI,OAAO,EAA6D,CAAC;AAElG,MAAM,cAAc,GAAG,CAAC,GAAsB,EAAE,IAAY,EAA6B,EAAE;IACzF,IAAI,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACnB,gBAAgB,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,MAAM,QAAQ,GAAG,GAAG,CAAC,eAAe,CAAC;QACnC,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACrB,aAAa,EAAE,KAAK,CAAC,KAAK,CAAC;gBACzB,YAAY,EAAE,CAAC,YAAY,CAAC;gBAC5B,KAAK,EAAE,GAAG,EAAE,CAAC,GAAG,IAAI,cAAc;aACnC,CAAC;SACH,CAAC;QACF,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IACH,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC3B,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,GAAsB,EAAE,OAAgB,EAAE,EAAE;;IAC5E,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAC3B,MAAM,WAAW,GAAG,cAAc,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAE9C,MAAM,EAAC,IAAI,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAC,GAAG,WAAW,CAAC,qBAAqB,EAAE,CAAC;IAC9E,MAAM,OAAO,GAA0B,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,IAAI,mCAAI,EAAE,CAAC,CAAC;IAEvF,OAAO,EAAC,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAC,CAAC;AAC9C,CAAC,CAAC"}
@@ -22,18 +22,19 @@ interface SubmitConsentMutationBuilder {
22
22
  };
23
23
  }) => unknown;
24
24
  }
25
+ interface SubmitConsentEnhancedApi {
26
+ useSubmitConsentResponseMutation: () => [
27
+ (body: SubmitConsentBody) => SubmitConsentMutationResult,
28
+ SubmitConsentMutationHookState
29
+ ];
30
+ }
25
31
  interface SubmitConsentApiWithTags {
26
32
  injectEndpoints: (options: {
27
33
  endpoints: (build: SubmitConsentMutationBuilder) => {
28
34
  submitConsentResponse: unknown;
29
35
  };
30
36
  overrideExisting: boolean;
31
- }) => {
32
- useSubmitConsentResponseMutation: () => [
33
- (body: SubmitConsentBody) => SubmitConsentMutationResult,
34
- SubmitConsentMutationHookState
35
- ];
36
- };
37
+ }) => SubmitConsentEnhancedApi;
37
38
  }
38
39
  interface SubmitConsentApi {
39
40
  enhanceEndpoints: (options: {
@@ -1,7 +1,23 @@
1
- export const useSubmitConsent = (api, baseUrl) => {
2
- const base = baseUrl || "";
1
+ /**
2
+ * Cache the enhanced api per (api, baseUrl). `injectEndpoints` logs a console
3
+ * error in development whenever an endpoint with the same name is re-injected
4
+ * (with `overrideExisting: false`), so calling it on every render of every
5
+ * consumer would flood the console. WeakMap-by-api lets the GC reclaim entries
6
+ * when the api object is unreachable.
7
+ */
8
+ const enhancedApiCache = new WeakMap();
9
+ const getEnhancedApi = (api, base) => {
10
+ let byBase = enhancedApiCache.get(api);
11
+ if (!byBase) {
12
+ byBase = new Map();
13
+ enhancedApiCache.set(api, byBase);
14
+ }
15
+ const cached = byBase.get(base);
16
+ if (cached) {
17
+ return cached;
18
+ }
3
19
  const apiWithConsentTags = api.enhanceEndpoints({ addTagTypes: ["PendingConsents"] });
4
- const enhancedApi = apiWithConsentTags.injectEndpoints({
20
+ const enhanced = apiWithConsentTags.injectEndpoints({
5
21
  endpoints: (build) => ({
6
22
  submitConsentResponse: build.mutation({
7
23
  invalidatesTags: ["PendingConsents"],
@@ -14,6 +30,12 @@ export const useSubmitConsent = (api, baseUrl) => {
14
30
  }),
15
31
  overrideExisting: false,
16
32
  });
33
+ byBase.set(base, enhanced);
34
+ return enhanced;
35
+ };
36
+ export const useSubmitConsent = (api, baseUrl) => {
37
+ const base = baseUrl || "";
38
+ const enhancedApi = getEnhancedApi(api, base);
17
39
  const [submitMutation, { isLoading: isSubmitting, error }] = enhancedApi.useSubmitConsentResponseMutation();
18
40
  const submit = async (body) => {
19
41
  return submitMutation(body).unwrap();
@@ -1 +1 @@
1
- {"version":3,"file":"useSubmitConsent.js","sourceRoot":"","sources":["../src/useSubmitConsent.ts"],"names":[],"mappings":"AA4CA,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,GAAqB,EAAE,OAAgB,EAAE,EAAE;IAC1E,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAC3B,MAAM,kBAAkB,GAAG,GAAG,CAAC,gBAAgB,CAAC,EAAC,WAAW,EAAE,CAAC,iBAAiB,CAAC,EAAC,CAAC,CAAC;IAEpF,MAAM,WAAW,GAAG,kBAAkB,CAAC,eAAe,CAAC;QACrD,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACrB,qBAAqB,EAAE,KAAK,CAAC,QAAQ,CAAC;gBACpC,eAAe,EAAE,CAAC,iBAAiB,CAAC;gBACpC,KAAK,EAAE,CAAC,IAAuB,EAAE,EAAE,CAAC,CAAC;oBACnC,IAAI;oBACJ,MAAM,EAAE,MAAM;oBACd,GAAG,EAAE,GAAG,IAAI,mBAAmB;iBAChC,CAAC;aACH,CAAC;SACH,CAAC;QACF,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IAEH,MAAM,CAAC,cAAc,EAAE,EAAC,SAAS,EAAE,YAAY,EAAE,KAAK,EAAC,CAAC,GACtD,WAAW,CAAC,gCAAgC,EAAE,CAAC;IAEjD,MAAM,MAAM,GAAG,KAAK,EAAE,IAAuB,EAAE,EAAE;QAC/C,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC;IACvC,CAAC,CAAC;IAEF,OAAO,EAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAC,CAAC;AACvC,CAAC,CAAC"}
1
+ {"version":3,"file":"useSubmitConsent.js","sourceRoot":"","sources":["../src/useSubmitConsent.ts"],"names":[],"mappings":"AA8CA;;;;;;GAMG;AACH,MAAM,gBAAgB,GAAG,IAAI,OAAO,EAA2D,CAAC;AAEhG,MAAM,cAAc,GAAG,CAAC,GAAqB,EAAE,IAAY,EAA4B,EAAE;IACvF,IAAI,MAAM,GAAG,gBAAgB,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,GAAG,IAAI,GAAG,EAAE,CAAC;QACnB,gBAAgB,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACpC,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;IAChC,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,MAAM,kBAAkB,GAAG,GAAG,CAAC,gBAAgB,CAAC,EAAC,WAAW,EAAE,CAAC,iBAAiB,CAAC,EAAC,CAAC,CAAC;IACpF,MAAM,QAAQ,GAAG,kBAAkB,CAAC,eAAe,CAAC;QAClD,SAAS,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;YACrB,qBAAqB,EAAE,KAAK,CAAC,QAAQ,CAAC;gBACpC,eAAe,EAAE,CAAC,iBAAiB,CAAC;gBACpC,KAAK,EAAE,CAAC,IAAuB,EAAE,EAAE,CAAC,CAAC;oBACnC,IAAI;oBACJ,MAAM,EAAE,MAAM;oBACd,GAAG,EAAE,GAAG,IAAI,mBAAmB;iBAChC,CAAC;aACH,CAAC;SACH,CAAC;QACF,gBAAgB,EAAE,KAAK;KACxB,CAAC,CAAC;IACH,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;IAC3B,OAAO,QAAQ,CAAC;AAClB,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,GAAqB,EAAE,OAAgB,EAAE,EAAE;IAC1E,MAAM,IAAI,GAAG,OAAO,IAAI,EAAE,CAAC;IAC3B,MAAM,WAAW,GAAG,cAAc,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAE9C,MAAM,CAAC,cAAc,EAAE,EAAC,SAAS,EAAE,YAAY,EAAE,KAAK,EAAC,CAAC,GACtD,WAAW,CAAC,gCAAgC,EAAE,CAAC;IAEjD,MAAM,MAAM,GAAG,KAAK,EAAE,IAAuB,EAAE,EAAE;QAC/C,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC;IACvC,CAAC,CAAC;IAEF,OAAO,EAAC,KAAK,EAAE,YAAY,EAAE,MAAM,EAAC,CAAC;AACvC,CAAC,CAAC"}
package/package.json CHANGED
@@ -137,5 +137,5 @@
137
137
  "test:coverage": "TZ=America/New_York bun run ../scripts/check-coverage.ts",
138
138
  "types": "bun typedoc"
139
139
  },
140
- "version": "0.13.0"
140
+ "version": "0.13.3"
141
141
  }
@@ -185,4 +185,51 @@ describe("ConsentFormScreen", () => {
185
185
  expect(getByTestId("consent-footer-scroll-hint")).toBeTruthy();
186
186
  expect(getByTestId("consent-footer-signature-hint")).toBeTruthy();
187
187
  });
188
+
189
+ it("shows the required-items legend when any checkbox is required", () => {
190
+ const form: ConsentFormPublic = {
191
+ ...baseForm,
192
+ checkboxes: [{label: "Required box", required: true}],
193
+ };
194
+ const {getByTestId} = renderWithTheme(
195
+ <ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
196
+ );
197
+ expect(getByTestId("consent-form-required-legend")).toBeTruthy();
198
+ });
199
+
200
+ it("hides the required-items legend when no checkbox is required", () => {
201
+ const form: ConsentFormPublic = {
202
+ ...baseForm,
203
+ checkboxes: [{label: "Optional box", required: false}],
204
+ };
205
+ const {queryByTestId} = renderWithTheme(
206
+ <ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
207
+ );
208
+ expect(queryByTestId("consent-form-required-legend")).toBeNull();
209
+ });
210
+
211
+ it("shows the checkbox footer hint when a required checkbox is unchecked", () => {
212
+ const form: ConsentFormPublic = {
213
+ ...baseForm,
214
+ checkboxes: [{label: "Required box", required: true}],
215
+ };
216
+ const {getByTestId} = renderWithTheme(
217
+ <ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
218
+ );
219
+ expect(getByTestId("consent-footer-checkboxes-hint")).toBeTruthy();
220
+ });
221
+
222
+ it("hides the checkbox footer hint once required checkboxes are checked", () => {
223
+ const form: ConsentFormPublic = {
224
+ ...baseForm,
225
+ checkboxes: [{label: "Required box", required: true}],
226
+ };
227
+ const {getByTestId, queryByTestId} = renderWithTheme(
228
+ <ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
229
+ );
230
+ act(() => {
231
+ fireEvent.press(getByTestId("consent-form-checkbox-0"));
232
+ });
233
+ expect(queryByTestId("consent-footer-checkboxes-hint")).toBeNull();
234
+ });
188
235
  });
@@ -50,6 +50,7 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
50
50
  });
51
51
 
52
52
  const signatureProvided = !form.captureSignature || Boolean(signatureValue);
53
+ const hasRequiredCheckboxes = form.checkboxes.some((checkbox) => checkbox.required);
53
54
 
54
55
  const canAgree = hasScrolledToBottom && allRequiredCheckboxesChecked && signatureProvided;
55
56
 
@@ -145,6 +146,11 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
145
146
  Please scroll to the bottom to continue
146
147
  </Text>
147
148
  )}
149
+ {Boolean(hasRequiredCheckboxes && !allRequiredCheckboxesChecked) && (
150
+ <Text align="center" color="error" size="sm" testID="consent-footer-checkboxes-hint">
151
+ Please check all required items marked with *
152
+ </Text>
153
+ )}
148
154
  {Boolean(form.captureSignature && !signatureValue) && (
149
155
  <Text align="center" color="error" size="sm" testID="consent-footer-signature-hint">
150
156
  Please provide your signature to continue
@@ -169,6 +175,11 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
169
175
 
170
176
  {form.checkboxes.length > 0 && (
171
177
  <Box direction="column" gap={2} testID="consent-form-checkboxes">
178
+ {hasRequiredCheckboxes && (
179
+ <Text color="secondaryDark" size="sm" testID="consent-form-required-legend">
180
+ * indicates a required item
181
+ </Text>
182
+ )}
172
183
  {form.checkboxes.map((checkbox, index) => {
173
184
  const key = index.toString();
174
185
  const isChecked = checkboxValues[key] ?? false;
@@ -206,3 +206,71 @@ describe("HeightField", () => {
206
206
  });
207
207
  });
208
208
  });
209
+
210
+ describe("HeightField - Android platform", () => {
211
+ // Toggle Platform.OS to "android" to exercise the Android rendering branch
212
+ // that uses SelectField pickers instead of the Pressable+ActionSheet path.
213
+ const {Platform} = require("react-native") as {Platform: {OS: string}};
214
+ const originalOS = Platform.OS;
215
+
216
+ beforeEach(() => {
217
+ Platform.OS = "android";
218
+ });
219
+
220
+ afterEach(() => {
221
+ Platform.OS = originalOS;
222
+ });
223
+
224
+ it("renders Android pickers with title, helperText, and errorText", () => {
225
+ const onChange = mock(() => {});
226
+ const {getByText, queryByLabelText} = renderWithTheme(
227
+ <HeightField
228
+ errorText="Required"
229
+ helperText="Enter height"
230
+ onChange={onChange}
231
+ title="Height"
232
+ value="70"
233
+ />
234
+ );
235
+ // Title and helper/error rendered
236
+ expect(getByText("Height")).toBeTruthy();
237
+ expect(getByText("Enter height")).toBeTruthy();
238
+ expect(getByText("Required")).toBeTruthy();
239
+ // The Pressable from the iOS branch should NOT be present.
240
+ expect(queryByLabelText("Height selector")).toBeNull();
241
+ });
242
+
243
+ it("renders Android pickers in disabled state", () => {
244
+ const onChange = mock(() => {});
245
+ const {root} = renderWithTheme(
246
+ <HeightField disabled onChange={onChange} title="Height" value="60" />
247
+ );
248
+ expect(root).toBeTruthy();
249
+ });
250
+
251
+ it("forwards feet picker changes to onChange (Android)", () => {
252
+ const onChange = mock(() => {});
253
+ const {SelectField} = require("./SelectField") as {
254
+ SelectField: React.ComponentType<{onChange?: (v: string) => void}>;
255
+ };
256
+ const {UNSAFE_getAllByType} = renderWithTheme(<HeightField onChange={onChange} value="70" />);
257
+ const selects = UNSAFE_getAllByType(SelectField);
258
+ expect(selects.length).toBe(2);
259
+ // First SelectField is feet. value="70" → 5ft 10in. Bumping feet to 6 yields 6*12+10=82.
260
+ selects[0].props.onChange?.("6");
261
+ expect(onChange).toHaveBeenCalledWith("82");
262
+ });
263
+
264
+ it("forwards inches picker changes to onChange (Android)", () => {
265
+ const onChange = mock(() => {});
266
+ const {SelectField} = require("./SelectField") as {
267
+ SelectField: React.ComponentType<{onChange?: (v: string) => void}>;
268
+ };
269
+ const {UNSAFE_getAllByType} = renderWithTheme(<HeightField onChange={onChange} value="70" />);
270
+ const selects = UNSAFE_getAllByType(SelectField);
271
+ expect(selects.length).toBe(2);
272
+ // Second SelectField is inches. value="70" → 5ft 10in. Changing inches to 3 yields 5*12+3=63.
273
+ selects[1].props.onChange?.("3");
274
+ expect(onChange).toHaveBeenCalledWith("63");
275
+ });
276
+ });
package/src/Modal.tsx CHANGED
@@ -12,7 +12,7 @@ import {Gesture, GestureDetector} from "react-native-gesture-handler";
12
12
  import {runOnJS} from "react-native-reanimated";
13
13
 
14
14
  import {Button} from "./Button";
15
- import type {ModalProps} from "./Common";
15
+ import type {ModalProps, TerrenoTheme} from "./Common";
16
16
  import {Heading} from "./Heading";
17
17
  import {Icon} from "./Icon";
18
18
  import {isMobileDevice} from "./MediaQuery";
@@ -45,7 +45,7 @@ const ModalContent: FC<{
45
45
  secondaryButtonOnClick?: ModalProps["secondaryButtonOnClick"];
46
46
  onDismiss: ModalProps["onDismiss"];
47
47
  sizePx: DimensionValue;
48
- theme: any;
48
+ theme: TerrenoTheme;
49
49
  isMobile: boolean;
50
50
  }> = ({
51
51
  children,
@@ -25,15 +25,18 @@
25
25
 
26
26
  import {Picker} from "@react-native-picker/picker";
27
27
  import isEqual from "lodash/isEqual";
28
- import {useCallback, useEffect, useMemo, useState} from "react";
28
+ import {type ComponentType, type ReactNode, useCallback, useEffect, useMemo, useState} from "react";
29
29
  import {
30
30
  Keyboard,
31
31
  Modal,
32
+ type ModalProps,
32
33
  Platform,
33
34
  Pressable,
35
+ type PressableProps,
34
36
  StyleSheet,
35
37
  Text,
36
38
  TextInput,
39
+ type TextInputProps,
37
40
  TouchableOpacity,
38
41
  View,
39
42
  } from "react-native";
@@ -67,14 +70,23 @@ export const defaultStyles = StyleSheet.create({
67
70
  },
68
71
  });
69
72
 
73
+ /** A single option for the picker select component. */
74
+ export interface PickerSelectItem {
75
+ label: string;
76
+ value: string | number | null;
77
+ key?: string | number;
78
+ color?: string;
79
+ inputLabel?: string;
80
+ }
81
+
70
82
  export interface RNPickerSelectProps {
71
- onValueChange: (value: any, index: any) => void;
72
- items: any[];
73
- value?: any;
74
- placeholder?: any;
83
+ onValueChange: (value: string | number | null, index: number) => void;
84
+ items: PickerSelectItem[];
85
+ value?: string | number | null;
86
+ placeholder?: Partial<PickerSelectItem>;
75
87
  disabled?: boolean;
76
88
  itemKey?: string | number;
77
- children?: any;
89
+ children?: ReactNode;
78
90
  onOpen?: () => void;
79
91
  useNativeAndroidPickerStyle?: boolean;
80
92
  fixAndroidTouchableBug?: boolean;
@@ -87,18 +99,18 @@ export interface RNPickerSelectProps {
87
99
  onClose?: () => void;
88
100
 
89
101
  // Modal props (iOS only)
90
- modalProps?: any;
102
+ modalProps?: Partial<ModalProps>;
91
103
 
92
104
  // TextInput props
93
- textInputProps?: any;
105
+ textInputProps?: Partial<TextInputProps>;
94
106
 
95
107
  // Touchable Done props (iOS only)
96
- touchableDoneProps?: any;
108
+ touchableDoneProps?: Partial<PressableProps>;
97
109
 
98
110
  // Touchable wrapper props
99
- touchableWrapperProps?: any;
111
+ touchableWrapperProps?: Partial<PressableProps>;
100
112
 
101
- InputAccessoryView?: any;
113
+ InputAccessoryView?: ComponentType<{testID?: string}>;
102
114
  }
103
115
 
104
116
  export function RNPickerSelect({
@@ -125,7 +137,9 @@ export function RNPickerSelect({
125
137
  InputAccessoryView,
126
138
  }: RNPickerSelectProps) {
127
139
  const [showPicker, setShowPicker] = useState<boolean>(false);
128
- const [animationType, setAnimationType] = useState(undefined);
140
+ const [animationType, setAnimationType] = useState<"none" | "slide" | "fade" | undefined>(
141
+ undefined
142
+ );
129
143
  const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
130
144
  const [doneDepressed, setDoneDepressed] = useState<boolean>(false);
131
145
  const {theme} = useTheme();
@@ -159,12 +173,12 @@ export function RNPickerSelect({
159
173
  }, [items, placeholder]);
160
174
 
161
175
  const getSelectedItem = useCallback(
162
- (key: any, val: any) => {
163
- let idx = options.findIndex((item: any) => {
164
- if (item.key && key) {
176
+ (key: string | number | undefined, val: string | number | null | undefined) => {
177
+ let idx = options.findIndex((item) => {
178
+ if (item?.key && key) {
165
179
  return isEqual(item.key, key);
166
180
  }
167
- return isEqual(item.value, val);
181
+ return isEqual(item?.value, val);
168
182
  });
169
183
  if (idx === -1) {
170
184
  idx = 0;
@@ -177,7 +191,7 @@ export function RNPickerSelect({
177
191
  [options]
178
192
  );
179
193
 
180
- const [selectedItem, setSelectedItem] = useState<any>(() => {
194
+ const [selectedItem, setSelectedItem] = useState<Partial<PickerSelectItem>>(() => {
181
195
  return getSelectedItem(itemKey, value).selectedItem;
182
196
  });
183
197
 
@@ -195,13 +209,17 @@ export function RNPickerSelect({
195
209
  togglePicker(false, onDownArrow);
196
210
  };
197
211
 
198
- const onValueChangeEvent = (val: any, index: any) => {
212
+ const onValueChangeEvent = (val: string | number | null, index: number) => {
199
213
  const item = getSelectedItem(itemKey, val);
200
214
  onValueChange(val, index);
201
215
  setSelectedItem(item.selectedItem);
202
216
  };
203
217
 
204
- const onOrientationChange = ({nativeEvent}: any) => {
218
+ const onOrientationChange = ({
219
+ nativeEvent,
220
+ }: {
221
+ nativeEvent: {orientation: "portrait" | "landscape"};
222
+ }) => {
205
223
  setOrientation(nativeEvent.orientation);
206
224
  };
207
225
 
@@ -215,7 +233,7 @@ export function RNPickerSelect({
215
233
  }
216
234
  };
217
235
 
218
- const togglePicker = (animate = false, postToggleCallback?: any) => {
236
+ const togglePicker = (animate = false, postToggleCallback?: () => void) => {
219
237
  if (disabled) {
220
238
  return;
221
239
  }
@@ -237,13 +255,13 @@ export function RNPickerSelect({
237
255
  };
238
256
 
239
257
  const renderPickerItems = () => {
240
- return options?.map((item: any) => {
258
+ return options?.map((item) => {
241
259
  return (
242
260
  <Picker.Item
243
- color={item.color}
244
- key={item.key || item.label}
245
- label={item.label}
246
- value={item.value}
261
+ color={item?.color}
262
+ key={item?.key || item?.label}
263
+ label={item?.label}
264
+ value={item?.value}
247
265
  />
248
266
  );
249
267
  });
@@ -408,7 +426,6 @@ export function RNPickerSelect({
408
426
  ]}
409
427
  >
410
428
  <Pressable
411
- activeOpacity={1}
412
429
  onPress={() => {
413
430
  togglePicker(true);
414
431
  }}
@@ -467,14 +484,11 @@ export function RNPickerSelect({
467
484
  };
468
485
 
469
486
  const renderAndroidHeadless = () => {
470
- const Component: any = fixAndroidTouchableBug ? View : Pressable;
487
+ // noExplicitAny: Component is View or Pressable depending on a bug workaround flag. View
488
+ // ignores Pressable-specific props (onPress) at runtime. A type-safe union cannot express this.
489
+ const Component = (fixAndroidTouchableBug ? View : Pressable) as unknown as typeof Pressable;
471
490
  return (
472
- <Component
473
- activeOpacity={1}
474
- onPress={onOpen}
475
- testID="android_touchable_wrapper"
476
- {...touchableWrapperProps}
477
- >
491
+ <Component onPress={onOpen} testID="android_touchable_wrapper" {...touchableWrapperProps}>
478
492
  <View>
479
493
  {renderTextInputOrChildren()}
480
494
  <Picker
@@ -636,7 +650,7 @@ export function RNPickerSelect({
636
650
  // Pass the original (non-stringified) value through so lodash
637
651
  // `isEqual` matching in `getSelectedItem` works for number /
638
652
  // object values.
639
- const originalValue = options[originalIndex]?.value;
653
+ const originalValue = options[originalIndex]?.value ?? null;
640
654
  onValueChangeEvent(originalValue, originalIndex);
641
655
  closeWebMenu();
642
656
  }}
@@ -3,7 +3,7 @@ import type React from "react";
3
3
  import {useCallback, useMemo, useState} from "react";
4
4
  import {Modal, Platform, Text, TouchableOpacity, View} from "react-native";
5
5
 
6
- import type {FieldOption, SelectBadgeProps, SurfaceTheme, TextTheme} from "./Common";
6
+ import type {FieldOption, IconColor, SelectBadgeProps, SurfaceTheme, TextTheme} from "./Common";
7
7
  import {Icon} from "./Icon";
8
8
  import {useTheme} from "./Theme";
9
9
  import {useWebDropdownAnchor, WebDropdownMenu, type WebDropdownMenuOption} from "./WebDropdownMenu";
@@ -98,7 +98,7 @@ export const SelectBadge = ({
98
98
  );
99
99
 
100
100
  const renderPickerItems = useCallback(() => {
101
- return options?.map((item: any) => (
101
+ return options?.map((item: FieldOption) => (
102
102
  <Picker.Item key={item.key || item.label} label={item.label} value={item.value} />
103
103
  ));
104
104
  }, [options]);
@@ -321,7 +321,11 @@ export const SelectBadge = ({
321
321
  }}
322
322
  >
323
323
  <Icon
324
- color={textColor as any}
324
+ // noExplicitAny: textColor is a resolved hex string from the theme, but Icon's
325
+ // color prop expects an IconColor key. Icon falls back to the raw string at runtime
326
+ // (theme.text[color] ?? color), but the type cannot be narrowed without changing
327
+ // Icon's type signature to accept arbitrary strings.
328
+ color={textColor as unknown as IconColor}
325
329
  iconName={showPicker ? "chevron-up" : "chevron-down"}
326
330
  size="sm"
327
331
  />
@@ -26,10 +26,10 @@ export const SelectField: FC<SelectFieldProps> = ({
26
26
  disabled={disabled}
27
27
  items={options}
28
28
  onValueChange={(v) => {
29
- if (v === undefined || v === "") {
29
+ if (v === undefined || v === null || v === "") {
30
30
  onChange("");
31
31
  } else {
32
- onChange(v);
32
+ onChange(String(v));
33
33
  }
34
34
  }}
35
35
  placeholder={!requireValue ? clearOption : {}}
@@ -5,7 +5,7 @@ import {Navigator, Slot} from "expo-router";
5
5
  // update the import path here — this is the only place in the codebase that references it.
6
6
  // eslint-disable-next-line import/no-internal-modules
7
7
  import {Screen} from "expo-router/build/views/Screen";
8
- import {type FC, useCallback, useEffect, useMemo, useRef, useState} from "react";
8
+ import {type FC, type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from "react";
9
9
  import {
10
10
  Animated,
11
11
  Dimensions,
@@ -361,7 +361,11 @@ const SidebarHeader: FC<{onOpen: () => void}> = ({onOpen}) => {
361
361
  const insets = useSafeAreaInsets();
362
362
  const {state, descriptors} = Navigator.useContext();
363
363
  const activeRoute = state.routes[state.index];
364
- const {headerLeft, headerRight, title} = (descriptors[activeRoute?.key]?.options ?? {}) as any;
364
+ const {headerLeft, headerRight, title} = (descriptors[activeRoute?.key]?.options ?? {}) as {
365
+ headerLeft?: (props: object) => ReactNode;
366
+ headerRight?: (props: object) => ReactNode;
367
+ title?: string;
368
+ };
365
369
 
366
370
  return (
367
371
  <View
@@ -18,6 +18,10 @@ export const Signature: FC<Props> = ({onChange, onStart, onEnd}: Props) => {
18
18
 
19
19
  const handleClear = () => {
20
20
  ref.current?.clearSignature();
21
+ // `clearSignature` on the underlying canvas does not fire `onOK`, so the
22
+ // parent never learns the signature is gone. Push an empty value so any
23
+ // "signature required" gating reflects the cleared state immediately.
24
+ onChange("");
21
25
  };
22
26
 
23
27
  const onBegin = () => {
@@ -49,6 +49,16 @@ describe("Signature", () => {
49
49
  expect(clearMock).toHaveBeenCalledTimes(1);
50
50
  });
51
51
 
52
+ it("notifies the parent with an empty value when Clear is pressed", () => {
53
+ clearMock.mockClear();
54
+ const mockOnChange = mock(() => {});
55
+ const {getByText} = renderWithTheme(<Signature onChange={mockOnChange} />);
56
+ fireEvent.press(getByText("Clear"));
57
+ // Without this, "signature required" gating in parents would never reset
58
+ // because the underlying canvas clear() does not fire onEnd/onOK.
59
+ expect(mockOnChange).toHaveBeenCalledWith("");
60
+ });
61
+
52
62
  it("calls onChange with the data URL when a stroke ends", () => {
53
63
  toDataURLReturn = "data:image/png;base64,abc";
54
64
  const mockOnChange = mock(() => {});
package/src/Signature.tsx CHANGED
@@ -17,6 +17,10 @@ export const Signature = ({onChange}: SignatureProps): ReactElement | null => {
17
17
 
18
18
  const onClear = () => {
19
19
  ref.current?.clear();
20
+ // `clear()` on the underlying canvas does not fire `onEnd`, so the parent
21
+ // never learns the signature is gone. Push an empty value so any
22
+ // "signature required" gating reflects the cleared state immediately.
23
+ onChange("");
20
24
  };
21
25
 
22
26
  const onUpdatedSignature = () => {