@terreno/ui 0.14.0 → 0.14.2

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 (98) hide show
  1. package/dist/ActionSheet.d.ts +1 -1
  2. package/dist/ActionSheet.js +17 -29
  3. package/dist/ActionSheet.js.map +1 -1
  4. package/dist/Common.d.ts +8 -2
  5. package/dist/Common.js +4 -4
  6. package/dist/Common.js.map +1 -1
  7. package/dist/ConsentFormScreen.js +3 -3
  8. package/dist/ConsentFormScreen.js.map +1 -1
  9. package/dist/DateUtilities.d.ts +25 -25
  10. package/dist/DateUtilities.js +31 -32
  11. package/dist/DateUtilities.js.map +1 -1
  12. package/dist/MarkdownView.js +20 -7
  13. package/dist/MarkdownView.js.map +1 -1
  14. package/dist/MediaQuery.d.ts +4 -4
  15. package/dist/MediaQuery.js +8 -8
  16. package/dist/MediaQuery.js.map +1 -1
  17. package/dist/Page.d.ts +1 -0
  18. package/dist/Page.js +6 -2
  19. package/dist/Page.js.map +1 -1
  20. package/dist/PickerSelect.d.ts +1 -1
  21. package/dist/PickerSelect.js +2 -2
  22. package/dist/PickerSelect.js.map +1 -1
  23. package/dist/TapToEdit.d.ts +1 -1
  24. package/dist/TapToEdit.js +2 -3
  25. package/dist/TapToEdit.js.map +1 -1
  26. package/dist/ToastNotifications.js +2 -2
  27. package/dist/ToastNotifications.js.map +1 -1
  28. package/dist/Tooltip.d.ts +24 -1
  29. package/dist/Tooltip.js +2 -2
  30. package/dist/Tooltip.js.map +1 -1
  31. package/dist/Unifier.d.ts +1 -1
  32. package/dist/Unifier.js +14 -11
  33. package/dist/Unifier.js.map +1 -1
  34. package/dist/Utilities.d.ts +8 -8
  35. package/dist/Utilities.js +12 -14
  36. package/dist/Utilities.js.map +1 -1
  37. package/dist/index.d.ts +1 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/signUp/PasswordRequirements.js +3 -3
  41. package/dist/signUp/PasswordRequirements.js.map +1 -1
  42. package/dist/table/TableHeaderCell.js +1 -9
  43. package/dist/table/TableHeaderCell.js.map +1 -1
  44. package/dist/table/tableContext.d.ts +1 -1
  45. package/dist/table/tableContext.js +2 -2
  46. package/dist/table/tableContext.js.map +1 -1
  47. package/dist/useConsentHistory.d.ts +6 -1
  48. package/dist/useConsentHistory.js +2 -1
  49. package/dist/useConsentHistory.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/ActionSheet.test.tsx +554 -0
  52. package/src/ActionSheet.tsx +26 -39
  53. package/src/Banner.test.tsx +107 -1
  54. package/src/Common.ts +10 -4
  55. package/src/ConsentFormScreen.test.tsx +22 -0
  56. package/src/ConsentFormScreen.tsx +9 -3
  57. package/src/DataTable.test.tsx +393 -1
  58. package/src/DateTimeField.test.tsx +716 -2
  59. package/src/DateUtilities.tsx +37 -38
  60. package/src/HeightActionSheet.test.tsx +17 -1
  61. package/src/HeightField.test.tsx +141 -1
  62. package/src/HeightFieldDesktop.test.tsx +19 -0
  63. package/src/MarkdownView.test.tsx +28 -0
  64. package/src/MarkdownView.tsx +69 -7
  65. package/src/MediaQuery.ts +8 -8
  66. package/src/MobileAddressAutoComplete.test.tsx +26 -3
  67. package/src/Page.test.tsx +28 -0
  68. package/src/Page.tsx +17 -2
  69. package/src/PickerSelect.test.tsx +243 -0
  70. package/src/PickerSelect.tsx +3 -3
  71. package/src/SplitPage.test.tsx +299 -43
  72. package/src/TapToEdit.test.tsx +44 -0
  73. package/src/TapToEdit.tsx +2 -3
  74. package/src/ToastNotifications.test.tsx +1412 -0
  75. package/src/ToastNotifications.tsx +2 -2
  76. package/src/Tooltip.test.tsx +1294 -3
  77. package/src/Tooltip.tsx +2 -2
  78. package/src/Unifier.ts +14 -11
  79. package/src/Utilities.tsx +14 -16
  80. package/src/WebAddressAutocomplete.test.tsx +237 -0
  81. package/src/WebDropdownMenu.test.tsx +51 -2
  82. package/src/__snapshots__/Banner.test.tsx.snap +125 -0
  83. package/src/__snapshots__/DataTable.test.tsx.snap +366 -0
  84. package/src/__snapshots__/MarkdownView.test.tsx.snap +284 -74
  85. package/src/__snapshots__/SplitPage.test.tsx.snap +698 -46
  86. package/src/bunSetup.ts +0 -4
  87. package/src/index.tsx +1 -1
  88. package/src/login/LoginScreen.test.tsx +35 -1
  89. package/src/signUp/PasswordRequirements.tsx +9 -6
  90. package/src/signUp/__snapshots__/PasswordRequirements.test.tsx.snap +50 -2
  91. package/src/signUp/__snapshots__/SignUpScreen.test.tsx.snap +25 -1
  92. package/src/table/TableHeaderCell.tsx +8 -11
  93. package/src/table/TableRow.test.tsx +31 -1
  94. package/src/table/__snapshots__/TableHeaderCell.test.tsx.snap +2 -0
  95. package/src/table/tableContext.tsx +2 -2
  96. package/src/useConsentHistory.test.ts +20 -13
  97. package/src/useConsentHistory.ts +7 -2
  98. package/src/useStoredState.test.tsx +47 -0
@@ -2,7 +2,7 @@ import {describe, expect, it, mock} from "bun:test";
2
2
  import {act, fireEvent, waitFor} from "@testing-library/react-native";
3
3
  import React from "react";
4
4
 
5
- import {Banner} from "./Banner";
5
+ import {Banner, hideBanner} from "./Banner";
6
6
  import {renderWithTheme} from "./test-utils";
7
7
  import {Unifier} from "./Unifier";
8
8
 
@@ -168,4 +168,110 @@ describe("Banner", () => {
168
168
  expect(setItemMock).not.toHaveBeenCalled();
169
169
  });
170
170
  });
171
+
172
+ it("hides banner when storage already has the dismissed flag", async () => {
173
+ const getItemMock = Unifier.storage.getItem as ReturnType<typeof mock>;
174
+ getItemMock.mockReturnValueOnce(Promise.resolve("true"));
175
+
176
+ const {queryByText} = renderWithTheme(
177
+ <Banner dismissible id="stored-banner" text="Previously dismissed" />
178
+ );
179
+
180
+ await waitFor(() => {
181
+ expect(queryByText("Previously dismissed")).toBeNull();
182
+ });
183
+ });
184
+
185
+ it("exercises the async .then path in useEffect", async () => {
186
+ const getItemMock = Unifier.storage.getItem as ReturnType<typeof mock>;
187
+ getItemMock.mockReturnValueOnce(Promise.resolve(null));
188
+
189
+ const {queryByText} = renderWithTheme(
190
+ <Banner dismissible id="flush-banner" text="Flush banner" />
191
+ );
192
+
193
+ await act(async () => {
194
+ await new Promise((resolve) => setTimeout(resolve, 50));
195
+ });
196
+
197
+ expect(queryByText("Flush banner")).toBeTruthy();
198
+ });
199
+
200
+ it("renders button without icon name (text-only button path)", async () => {
201
+ const handleClick = mock(() => Promise.resolve());
202
+ const {getByText} = renderWithTheme(
203
+ <Banner
204
+ buttonOnClick={handleClick}
205
+ buttonText="TextOnly"
206
+ id="textonly-banner"
207
+ text="Banner"
208
+ />
209
+ );
210
+ expect(getByText("TextOnly")).toBeTruthy();
211
+
212
+ await act(async () => {
213
+ fireEvent.press(getByText("TextOnly"));
214
+ await new Promise((resolve) => setTimeout(resolve, 100));
215
+ });
216
+
217
+ await waitFor(() => {
218
+ expect(handleClick).toHaveBeenCalled();
219
+ });
220
+ });
221
+
222
+ it("covers catch block when buttonOnClick rejects", async () => {
223
+ const handleClick = mock(() => Promise.reject(new Error("boom")));
224
+ const {UNSAFE_root} = renderWithTheme(
225
+ <Banner buttonOnClick={handleClick} buttonText="Fail" id="catch-banner" text="Banner" />
226
+ );
227
+
228
+ const pressable = UNSAFE_root.findAll(
229
+ (node) => node.props?.["aria-label"] === "Fail" && typeof node.props?.onPress === "function"
230
+ )[0];
231
+
232
+ try {
233
+ await act(async () => {
234
+ await pressable.props.onPress();
235
+ });
236
+ } catch (_e) {
237
+ // Expected: catch block in BannerButton re-throws
238
+ }
239
+
240
+ expect(handleClick).toHaveBeenCalled();
241
+ });
242
+
243
+ it("hideBanner persists the banner id to storage", async () => {
244
+ const setItemMock = Unifier.storage.setItem as ReturnType<typeof mock>;
245
+ setItemMock.mockClear();
246
+
247
+ await hideBanner("my-banner");
248
+ expect(setItemMock).toHaveBeenCalledWith("@TerrenoUI:my-banner", "true");
249
+ });
250
+
251
+ it("renders with button loading state", () => {
252
+ const handleClick = mock(() => Promise.resolve());
253
+ const {toJSON} = renderWithTheme(
254
+ <Banner
255
+ buttonOnClick={handleClick}
256
+ buttonText="Loading"
257
+ id="test-banner"
258
+ loading
259
+ text="Banner loading"
260
+ />
261
+ );
262
+ expect(toJSON()).toMatchSnapshot();
263
+ });
264
+
265
+ it("renders banner with dismissible=true and no id (non-persistent dismiss)", async () => {
266
+ const {getByLabelText, queryByText} = renderWithTheme(
267
+ <Banner dismissible text="Non persistent" />
268
+ );
269
+ expect(queryByText("Non persistent")).toBeTruthy();
270
+ await act(async () => {
271
+ fireEvent.press(getByLabelText("Dismiss"));
272
+ });
273
+ await waitFor(() => {
274
+ expect(queryByText("Non persistent")).toBeNull();
275
+ });
276
+ });
171
277
  });
package/src/Common.ts CHANGED
@@ -395,12 +395,12 @@ export const SPACING_MAP = {
395
395
  12: 80,
396
396
  };
397
397
 
398
- export function getSpacing(spacing: SignedUpTo12) {
398
+ export const getSpacing = (spacing: SignedUpTo12) => {
399
399
  if (spacing < 0) {
400
400
  return SPACING_MAP[Math.abs(spacing) as UnsignedUpTo12] * -1;
401
401
  }
402
402
  return SPACING_MAP[spacing as UnsignedUpTo12];
403
- }
403
+ };
404
404
 
405
405
  export type TextFieldType =
406
406
  | "date"
@@ -753,9 +753,9 @@ const ROUNDING_MAP = {
753
753
  xl: 32,
754
754
  };
755
755
 
756
- export function getRounding(rounding: Rounding) {
756
+ export const getRounding = (rounding: Rounding) => {
757
757
  return ROUNDING_MAP[rounding];
758
- }
758
+ };
759
759
 
760
760
  export interface HeadingProps {
761
761
  align?: "left" | "right" | "center" | "justify"; // default "left"
@@ -1965,6 +1965,12 @@ export interface PageProps {
1965
1965
  rightButtonOnClick?: () => void;
1966
1966
  children?: ReactChildren;
1967
1967
  onError?: (error: Error, stack: string) => void;
1968
+ /**
1969
+ * When true, wraps content in SafeAreaView so it respects top/bottom device
1970
+ * insets (camera notches, home indicator, status bar). Opt-in to avoid
1971
+ * regressing existing screens that handle insets at the navigation level.
1972
+ */
1973
+ safeArea?: boolean;
1968
1974
  }
1969
1975
 
1970
1976
  export interface ProgressBarProps {
@@ -2,6 +2,7 @@ import {describe, expect, it, mock} from "bun:test";
2
2
  import {act, fireEvent} from "@testing-library/react-native";
3
3
 
4
4
  import {ConsentFormScreen} from "./ConsentFormScreen";
5
+ import {SignatureField} from "./SignatureField";
5
6
  import {renderWithTheme} from "./test-utils";
6
7
  import type {ConsentFormPublic} from "./useConsentForms";
7
8
 
@@ -334,4 +335,25 @@ describe("ConsentFormScreen", () => {
334
335
  });
335
336
  expect(queryByTestId("consent-footer-checkboxes-hint")).toBeNull();
336
337
  });
338
+
339
+ it("exercises SignatureField onChange, onStart, and onEnd callbacks", () => {
340
+ const form: ConsentFormPublic = {
341
+ ...baseForm,
342
+ captureSignature: true,
343
+ };
344
+ const {UNSAFE_getByType} = renderWithTheme(
345
+ <ConsentFormScreen form={form} locale="en" onAgree={() => {}} />
346
+ );
347
+ const sig = UNSAFE_getByType(SignatureField);
348
+ act(() => {
349
+ sig.props.onChange("data:image/png;base64,abc");
350
+ });
351
+ act(() => {
352
+ sig.props.onStart();
353
+ });
354
+ act(() => {
355
+ sig.props.onEnd();
356
+ });
357
+ expect(sig).toBeTruthy();
358
+ });
337
359
  });
@@ -3,8 +3,10 @@ import {
3
3
  type LayoutChangeEvent,
4
4
  type NativeScrollEvent,
5
5
  type NativeSyntheticEvent,
6
+ Platform,
6
7
  Pressable,
7
8
  ScrollView,
9
+ View,
8
10
  } from "react-native";
9
11
 
10
12
  import {Box} from "./Box";
@@ -166,7 +168,7 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
166
168
  );
167
169
 
168
170
  return (
169
- <Page color="base" footer={footer} maxWidth="100%" scroll={false} title={form.title}>
171
+ <Page color="base" footer={footer} maxWidth="100%" safeArea scroll={false} title={form.title}>
170
172
  <ScrollView
171
173
  onContentSizeChange={handleContentSizeChange}
172
174
  onLayout={handleLayout}
@@ -212,7 +214,11 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
212
214
  )}
213
215
 
214
216
  {Boolean(form.captureSignature) && (
215
- <Box direction="column" gap={2} testID="consent-form-signature">
217
+ <View
218
+ onTouchEnd={Platform.OS === "ios" ? () => setScrollEnabled(true) : undefined}
219
+ onTouchStart={Platform.OS === "ios" ? () => setScrollEnabled(false) : undefined}
220
+ testID="consent-form-signature"
221
+ >
216
222
  <SignatureField
217
223
  onChange={(value) => setSignatureValue(value)}
218
224
  onEnd={() => setScrollEnabled(true)}
@@ -220,7 +226,7 @@ export const ConsentFormScreen: React.FC<ConsentFormScreenProps> = ({
220
226
  title="Signature"
221
227
  value={signatureValue}
222
228
  />
223
- </Box>
229
+ </View>
224
230
  )}
225
231
 
226
232
  {Boolean(form.requireScrollToBottom && !hasScrolledToBottom) && (
@@ -1,6 +1,8 @@
1
1
  // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
2
2
  import {describe, expect, it, mock} from "bun:test";
3
+ import {act} from "@testing-library/react-native";
3
4
 
5
+ import type {DataTableCustomComponentMap, DataTableProps} from "./Common";
4
6
  import {DataTable} from "./DataTable";
5
7
  import {Text} from "./Text";
6
8
  import {renderWithTheme} from "./test-utils";
@@ -114,7 +116,7 @@ describe("DataTable", () => {
114
116
  <DataTable
115
117
  columns={sampleColumns}
116
118
  data={sampleData}
117
- moreContentComponent={MoreContent as any}
119
+ moreContentComponent={MoreContent as unknown as DataTableProps["moreContentComponent"]}
118
120
  />
119
121
  );
120
122
  expect(toJSON()).toMatchSnapshot();
@@ -131,4 +133,394 @@ describe("DataTable", () => {
131
133
  const {toJSON} = renderWithTheme(<DataTable columns={sampleColumns} data={[]} />);
132
134
  expect(toJSON()).toMatchSnapshot();
133
135
  });
136
+
137
+ it("handles sort cycling: none -> asc -> desc -> none", () => {
138
+ const sortableColumns = [
139
+ {columnType: "text", sortable: true, title: "Name", width: 150},
140
+ {columnType: "text", sortable: false, title: "Age", width: 100},
141
+ ];
142
+ const setSortColumn = mock((_sort?: {column: number; direction: string}) => {});
143
+ const {UNSAFE_getAllByType} = renderWithTheme(
144
+ <DataTable
145
+ columns={sortableColumns}
146
+ data={[[{value: "Alice"}, {value: "28"}]]}
147
+ setSortColumn={setSortColumn}
148
+ />
149
+ );
150
+
151
+ const {Pressable: PressableComp} = require("react-native");
152
+ const pressables = UNSAFE_getAllByType(PressableComp);
153
+ // Find the sort pressable (has hitSlop=16)
154
+ const sortPressable = pressables.find(
155
+ (p: {props: {hitSlop?: number}}) => p.props.hitSlop === 16
156
+ );
157
+ expect(sortPressable).toBeTruthy();
158
+
159
+ const {fireEvent} = require("@testing-library/react-native");
160
+ // First click: none -> asc
161
+ fireEvent.press(sortPressable!);
162
+ expect(setSortColumn).toHaveBeenCalledWith({column: 0, direction: "asc"});
163
+ });
164
+
165
+ it("handles sort from asc to desc", () => {
166
+ const sortableColumns = [{columnType: "text", sortable: true, title: "Name", width: 150}];
167
+ const setSortColumn = mock((_sort?: {column: number; direction: string}) => {});
168
+ const {UNSAFE_getAllByType} = renderWithTheme(
169
+ <DataTable
170
+ columns={sortableColumns}
171
+ data={[[{value: "Alice"}]]}
172
+ setSortColumn={setSortColumn}
173
+ sortColumn={{column: 0, direction: "asc"}}
174
+ />
175
+ );
176
+
177
+ const {Pressable: PressableComp} = require("react-native");
178
+ const pressables = UNSAFE_getAllByType(PressableComp);
179
+ const sortPressable = pressables.find(
180
+ (p: {props: {hitSlop?: number}}) => p.props.hitSlop === 16
181
+ );
182
+
183
+ const {fireEvent} = require("@testing-library/react-native");
184
+ fireEvent.press(sortPressable!);
185
+ expect(setSortColumn).toHaveBeenCalledWith({column: 0, direction: "desc"});
186
+ });
187
+
188
+ it("handles sort from desc to none", () => {
189
+ const sortableColumns = [{columnType: "text", sortable: true, title: "Name", width: 150}];
190
+ const setSortColumn = mock((_sort?: {column: number; direction: string}) => {});
191
+ const {UNSAFE_getAllByType} = renderWithTheme(
192
+ <DataTable
193
+ columns={sortableColumns}
194
+ data={[[{value: "Alice"}]]}
195
+ setSortColumn={setSortColumn}
196
+ sortColumn={{column: 0, direction: "desc"}}
197
+ />
198
+ );
199
+
200
+ const {Pressable: PressableComp} = require("react-native");
201
+ const pressables = UNSAFE_getAllByType(PressableComp);
202
+ const sortPressable = pressables.find(
203
+ (p: {props: {hitSlop?: number}}) => p.props.hitSlop === 16
204
+ );
205
+
206
+ const {fireEvent} = require("@testing-library/react-native");
207
+ fireEvent.press(sortPressable!);
208
+ expect(setSortColumn).toHaveBeenCalledWith(undefined);
209
+ });
210
+
211
+ it("handles sort on non-sortable column (no-op)", () => {
212
+ const columns = [{columnType: "text", sortable: false, title: "Name", width: 150}];
213
+ const setSortColumn = mock(() => {});
214
+ renderWithTheme(
215
+ <DataTable columns={columns} data={[[{value: "Alice"}]]} setSortColumn={setSortColumn} />
216
+ );
217
+ // No sort pressable rendered for non-sortable columns, so no action needed
218
+ expect(setSortColumn).not.toHaveBeenCalled();
219
+ });
220
+
221
+ it("syncs scroll between header and body via refs", () => {
222
+ const {UNSAFE_getAllByType} = renderWithTheme(
223
+ <DataTable columns={sampleColumns} data={sampleData} pinnedColumns={1} />
224
+ );
225
+
226
+ const {ScrollView: ScrollViewComp} = require("react-native");
227
+ const scrollViews = UNSAFE_getAllByType(ScrollViewComp);
228
+ expect(scrollViews.length).toBeGreaterThan(0);
229
+
230
+ // Inject mock scrollTo on the refs so handleScroll branches execute
231
+ const mockScrollTo = mock((_opts: {animated: boolean; x: number}) => {});
232
+ for (const sv of scrollViews) {
233
+ if (sv.props.horizontal) {
234
+ const fiber = (sv as unknown as {_fiber?: {ref?: {current: unknown}}})._fiber;
235
+ if (fiber?.ref && typeof fiber.ref === "object") {
236
+ fiber.ref.current = {scrollTo: mockScrollTo};
237
+ }
238
+ }
239
+ }
240
+
241
+ const {fireEvent} = require("@testing-library/react-native");
242
+ // Find header scroll (onScroll passes isHeader=true)
243
+ const headerScroll = scrollViews.find(
244
+ (sv: {props: {horizontal?: boolean; showsHorizontalScrollIndicator?: boolean}}) =>
245
+ sv.props.horizontal && sv.props.showsHorizontalScrollIndicator === false
246
+ );
247
+ // Find body scroll (onScroll passes isHeader=false)
248
+ const bodyScroll = scrollViews.find(
249
+ (sv: {props: {horizontal?: boolean; showsHorizontalScrollIndicator?: boolean}}) =>
250
+ sv.props.horizontal && sv.props.showsHorizontalScrollIndicator === true
251
+ );
252
+
253
+ if (headerScroll) {
254
+ fireEvent.scroll(headerScroll, {
255
+ nativeEvent: {contentOffset: {x: 50, y: 0}},
256
+ });
257
+ }
258
+ if (bodyScroll) {
259
+ fireEvent.scroll(bodyScroll, {
260
+ nativeEvent: {contentOffset: {x: 75, y: 0}},
261
+ });
262
+ }
263
+
264
+ expect(mockScrollTo).toHaveBeenCalled();
265
+ });
266
+
267
+ it("renders with custom column component map", () => {
268
+ const CustomComponent = ({cellData}: {cellData: {value: unknown}}) => (
269
+ <Text>Custom: {String(cellData.value)}</Text>
270
+ );
271
+ const customColumns = [{columnType: "custom", title: "Custom Col", width: 150}];
272
+ const customData = [[{value: "test"}]];
273
+ const {getByText} = renderWithTheme(
274
+ <DataTable
275
+ columns={customColumns}
276
+ customColumnComponentMap={{custom: CustomComponent as any}}
277
+ data={customData}
278
+ />
279
+ );
280
+ expect(getByText("Custom: test")).toBeTruthy();
281
+ });
282
+
283
+ it("renders with infoModalText on column header", () => {
284
+ const columnsWithInfo = [
285
+ {columnType: "text", infoModalText: "**Help text**", title: "Name", width: 150},
286
+ ];
287
+ const {toJSON} = renderWithTheme(
288
+ <DataTable columns={columnsWithInfo} data={[[{value: "Alice"}]]} />
289
+ );
290
+ expect(toJSON()).toBeTruthy();
291
+ });
292
+
293
+ it("renders with cell highlight", () => {
294
+ const highlightData = [[{highlight: "primary", value: "Highlighted"}]];
295
+ const {toJSON} = renderWithTheme(
296
+ <DataTable columns={[{columnType: "text", title: "Name", width: 150}]} data={highlightData} />
297
+ );
298
+ expect(toJSON()).toBeTruthy();
299
+ });
300
+
301
+ it("renders with moreContentExtraData", () => {
302
+ const MoreContent = ({rowIndex, extraInfo}: {rowIndex: number; extraInfo?: string}) => (
303
+ <Text>
304
+ Row {rowIndex}: {extraInfo}
305
+ </Text>
306
+ );
307
+ const {toJSON} = renderWithTheme(
308
+ <DataTable
309
+ columns={sampleColumns}
310
+ data={sampleData}
311
+ moreContentComponent={MoreContent as any}
312
+ moreContentExtraData={[{extraInfo: "info1"}, {extraInfo: "info2"}, {extraInfo: "info3"}]}
313
+ />
314
+ );
315
+ expect(toJSON()).toBeTruthy();
316
+ });
317
+
318
+ it("opens and dismisses more content modal via MoreButtonCell press", async () => {
319
+ const MoreContent = ({rowIndex}: {rowIndex: number}) => <Text>Detail for row {rowIndex}</Text>;
320
+ const {UNSAFE_getAllByType} = renderWithTheme(
321
+ <DataTable
322
+ columns={sampleColumns}
323
+ data={sampleData}
324
+ moreContentComponent={MoreContent as any}
325
+ />
326
+ );
327
+
328
+ const {Pressable: PressableComp} = require("react-native");
329
+ const {fireEvent} = require("@testing-library/react-native");
330
+ const pressables = UNSAFE_getAllByType(PressableComp);
331
+
332
+ // Find the info/eye icon pressable (MoreButtonCell has accessibilityHint="View details")
333
+ const moreBtn = pressables.find(
334
+ (p: {props: {accessibilityHint?: string}}) => p.props.accessibilityHint === "View details"
335
+ );
336
+ expect(moreBtn).toBeTruthy();
337
+
338
+ // Press to open modal
339
+ await act(async () => {
340
+ fireEvent.press(moreBtn!);
341
+ });
342
+
343
+ // Find the modal dismiss pressable and press it
344
+ const {Modal: ModalComp} = require("./Modal");
345
+ const modals = UNSAFE_getAllByType(ModalComp);
346
+ if (modals.length > 0 && modals[0].props.onDismiss) {
347
+ await act(async () => {
348
+ modals[0].props.onDismiss();
349
+ });
350
+ }
351
+ });
352
+
353
+ it("renders with customColumnComponentMap", () => {
354
+ const CustomCell = ({cellData}: {cellData: {value: unknown}; column: unknown}) => (
355
+ <Text>Custom: {String(cellData.value)}</Text>
356
+ );
357
+ const customColumns = [
358
+ {columnType: "custom", title: "Custom Col", width: 150},
359
+ {columnType: "text", title: "Name", width: 100},
360
+ ];
361
+ const customData = [[{value: "A"}, {value: "Bob"}]];
362
+ const {getByText} = renderWithTheme(
363
+ <DataTable
364
+ columns={customColumns}
365
+ customColumnComponentMap={{custom: CustomCell} as DataTableCustomComponentMap}
366
+ data={customData}
367
+ />
368
+ );
369
+ expect(getByText("Custom: A")).toBeTruthy();
370
+ });
371
+
372
+ it("handleSort cycles through asc, desc, and clear", () => {
373
+ const sortableColumns = [
374
+ {columnType: "text", sortable: true, title: "Name", width: 150},
375
+ {columnType: "text", sortable: false, title: "Age", width: 100},
376
+ ];
377
+ const setSortColumn = mock((_val: unknown) => {});
378
+ const {UNSAFE_getAllByType} = renderWithTheme(
379
+ <DataTable
380
+ columns={sortableColumns}
381
+ data={[[{value: "Alice"}, {value: "28"}]]}
382
+ setSortColumn={setSortColumn}
383
+ />
384
+ );
385
+
386
+ const {Pressable: PressableComp} = require("react-native");
387
+ const pressables = UNSAFE_getAllByType(PressableComp);
388
+ const sortButton = pressables.find((p: {props: {hitSlop?: number}}) => p.props.hitSlop === 16);
389
+ expect(sortButton).toBeTruthy();
390
+
391
+ // First press: asc
392
+ act(() => {
393
+ sortButton!.props.onPress();
394
+ });
395
+ expect(setSortColumn).toHaveBeenCalledWith({column: 0, direction: "asc"});
396
+ });
397
+
398
+ it("handleSort from asc to desc", () => {
399
+ const sortableColumns = [{columnType: "text", sortable: true, title: "Name", width: 150}];
400
+ const setSortColumn = mock((_val: unknown) => {});
401
+ const {UNSAFE_getAllByType} = renderWithTheme(
402
+ <DataTable
403
+ columns={sortableColumns}
404
+ data={[[{value: "Alice"}]]}
405
+ setSortColumn={setSortColumn}
406
+ sortColumn={{column: 0, direction: "asc"}}
407
+ />
408
+ );
409
+
410
+ const {Pressable: PressableComp} = require("react-native");
411
+ const pressables = UNSAFE_getAllByType(PressableComp);
412
+ const sortButton = pressables.find((p: {props: {hitSlop?: number}}) => p.props.hitSlop === 16);
413
+
414
+ act(() => {
415
+ sortButton!.props.onPress();
416
+ });
417
+ expect(setSortColumn).toHaveBeenCalledWith({column: 0, direction: "desc"});
418
+ });
419
+
420
+ it("handleSort from desc clears sort", () => {
421
+ const sortableColumns = [{columnType: "text", sortable: true, title: "Name", width: 150}];
422
+ const setSortColumn = mock((_val: unknown) => {});
423
+ const {UNSAFE_getAllByType} = renderWithTheme(
424
+ <DataTable
425
+ columns={sortableColumns}
426
+ data={[[{value: "Alice"}]]}
427
+ setSortColumn={setSortColumn}
428
+ sortColumn={{column: 0, direction: "desc"}}
429
+ />
430
+ );
431
+
432
+ const {Pressable: PressableComp} = require("react-native");
433
+ const pressables = UNSAFE_getAllByType(PressableComp);
434
+ const sortButton = pressables.find((p: {props: {hitSlop?: number}}) => p.props.hitSlop === 16);
435
+
436
+ act(() => {
437
+ sortButton!.props.onPress();
438
+ });
439
+ expect(setSortColumn).toHaveBeenCalledWith(undefined);
440
+ });
441
+
442
+ it("handleSort does nothing for non-sortable column", () => {
443
+ const columns = [{columnType: "text", sortable: false, title: "Name", width: 150}];
444
+ const setSortColumn = mock((_val: unknown) => {});
445
+ const {toJSON} = renderWithTheme(
446
+ <DataTable columns={columns} data={[[{value: "Alice"}]]} setSortColumn={setSortColumn} />
447
+ );
448
+ expect(toJSON()).toBeTruthy();
449
+ });
450
+
451
+ it("handleScroll syncs header and body scroll positions", () => {
452
+ const {UNSAFE_getAllByType} = renderWithTheme(
453
+ <DataTable columns={sampleColumns} data={sampleData} pinnedColumns={1} />
454
+ );
455
+
456
+ const {ScrollView: ScrollViewComp} = require("react-native");
457
+ const scrollViews = UNSAFE_getAllByType(ScrollViewComp);
458
+ const horizontalScrollViews = scrollViews.filter(
459
+ (sv: {props: {horizontal?: boolean}}) => sv.props.horizontal
460
+ );
461
+
462
+ if (horizontalScrollViews.length >= 2) {
463
+ // Trigger scroll on the body scroll view
464
+ act(() => {
465
+ horizontalScrollViews[1].props.onScroll({
466
+ nativeEvent: {contentOffset: {x: 50, y: 0}},
467
+ });
468
+ });
469
+
470
+ // Trigger scroll on the header scroll view
471
+ act(() => {
472
+ horizontalScrollViews[0].props.onScroll({
473
+ nativeEvent: {contentOffset: {x: 100, y: 0}},
474
+ });
475
+ });
476
+ }
477
+ });
478
+
479
+ it("renders with cell highlight color", () => {
480
+ const highlightData = [[{highlight: "primary", value: "Highlighted"}]];
481
+ const highlightColumns = [{columnType: "text", title: "Col", width: 150}];
482
+ const {toJSON} = renderWithTheme(<DataTable columns={highlightColumns} data={highlightData} />);
483
+ expect(toJSON()).toBeTruthy();
484
+ });
485
+
486
+ it("opens and closes more content modal", () => {
487
+ const MoreContent = ({rowIndex}: {rowIndex: number}) => <Text>Row {rowIndex} details</Text>;
488
+ const {UNSAFE_getAllByType} = renderWithTheme(
489
+ <DataTable
490
+ columns={sampleColumns}
491
+ data={sampleData}
492
+ moreContentComponent={MoreContent as unknown as DataTableProps["moreContentComponent"]}
493
+ />
494
+ );
495
+
496
+ const {Pressable: PressableComp} = require("react-native");
497
+ const pressables = UNSAFE_getAllByType(PressableComp);
498
+ // Find the "Open modal" button (MoreButtonCell)
499
+ const moreButton = pressables.find(
500
+ (p: {props: {accessibilityLabel?: string}}) => p.props.accessibilityLabel === "Open modal"
501
+ );
502
+
503
+ if (moreButton) {
504
+ act(() => {
505
+ moreButton.props.onPress();
506
+ });
507
+ }
508
+ });
509
+
510
+ it("handleSort with no setSortColumn is a no-op", () => {
511
+ const sortableColumns = [{columnType: "text", sortable: true, title: "Name", width: 150}];
512
+ const {UNSAFE_getAllByType} = renderWithTheme(
513
+ <DataTable columns={sortableColumns} data={[[{value: "Alice"}]]} />
514
+ );
515
+
516
+ const {Pressable: PressableComp} = require("react-native");
517
+ const pressables = UNSAFE_getAllByType(PressableComp);
518
+ const sortButton = pressables.find((p: {props: {hitSlop?: number}}) => p.props.hitSlop === 16);
519
+
520
+ if (sortButton) {
521
+ act(() => {
522
+ sortButton.props.onPress();
523
+ });
524
+ }
525
+ });
134
526
  });