@utilitywarehouse/hearth-react-native 0.27.3 → 0.28.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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-lint.log +18 -19
  3. package/CHANGELOG.md +110 -0
  4. package/build/components/Combobox/Combobox.context.d.ts +13 -0
  5. package/build/components/Combobox/Combobox.context.js +9 -0
  6. package/build/components/Combobox/Combobox.d.ts +6 -0
  7. package/build/components/Combobox/Combobox.js +246 -0
  8. package/build/components/Combobox/Combobox.props.d.ts +180 -0
  9. package/build/components/Combobox/Combobox.props.js +1 -0
  10. package/build/components/Combobox/ComboboxOption.d.ts +6 -0
  11. package/build/components/Combobox/ComboboxOption.js +56 -0
  12. package/build/components/Combobox/index.d.ts +4 -0
  13. package/build/components/Combobox/index.js +3 -0
  14. package/build/components/Modal/Modal.js +26 -42
  15. package/build/components/Modal/Modal.web.js +3 -3
  16. package/build/components/Pagination/Pagination.d.ts +6 -0
  17. package/build/components/Pagination/Pagination.js +125 -0
  18. package/build/components/Pagination/Pagination.props.d.ts +26 -0
  19. package/build/components/Pagination/Pagination.props.js +1 -0
  20. package/build/components/Pagination/Pagination.utils.d.ts +2 -0
  21. package/build/components/Pagination/Pagination.utils.js +20 -0
  22. package/build/components/Pagination/Pagination.utils.test.d.ts +1 -0
  23. package/build/components/Pagination/Pagination.utils.test.js +16 -0
  24. package/build/components/Pagination/index.d.ts +2 -0
  25. package/build/components/Pagination/index.js +1 -0
  26. package/build/components/SafeAreaView/SafeAreaView.d.ts +5 -0
  27. package/build/components/SafeAreaView/SafeAreaView.js +117 -0
  28. package/build/components/SafeAreaView/SafeAreaView.props.d.ts +17 -0
  29. package/build/components/SafeAreaView/SafeAreaView.props.js +1 -0
  30. package/build/components/SafeAreaView/index.d.ts +2 -0
  31. package/build/components/SafeAreaView/index.js +1 -0
  32. package/build/components/Select/Select.js +3 -2
  33. package/build/components/Table/Table.context.d.ts +12 -0
  34. package/build/components/Table/Table.context.js +9 -0
  35. package/build/components/Table/Table.d.ts +6 -0
  36. package/build/components/Table/Table.js +71 -0
  37. package/build/components/Table/Table.props.d.ts +56 -0
  38. package/build/components/Table/Table.props.js +1 -0
  39. package/build/components/Table/Table.utils.d.ts +5 -0
  40. package/build/components/Table/Table.utils.js +48 -0
  41. package/build/components/Table/Table.utils.test.d.ts +1 -0
  42. package/build/components/Table/Table.utils.test.js +71 -0
  43. package/build/components/Table/TableBody.d.ts +6 -0
  44. package/build/components/Table/TableBody.js +16 -0
  45. package/build/components/Table/TableCell.d.ts +10 -0
  46. package/build/components/Table/TableCell.js +44 -0
  47. package/build/components/Table/TableHeader.d.ts +6 -0
  48. package/build/components/Table/TableHeader.js +24 -0
  49. package/build/components/Table/TableHeaderCell.d.ts +10 -0
  50. package/build/components/Table/TableHeaderCell.js +97 -0
  51. package/build/components/Table/TablePagination.d.ts +6 -0
  52. package/build/components/Table/TablePagination.js +7 -0
  53. package/build/components/Table/TableRow.d.ts +8 -0
  54. package/build/components/Table/TableRow.js +25 -0
  55. package/build/components/Table/index.d.ts +8 -0
  56. package/build/components/Table/index.js +7 -0
  57. package/build/components/Timeline/Timeline.d.ts +6 -0
  58. package/build/components/Timeline/Timeline.js +34 -0
  59. package/build/components/Timeline/Timeline.props.d.ts +47 -0
  60. package/build/components/Timeline/Timeline.props.js +1 -0
  61. package/build/components/Timeline/TimelineItem.d.ts +6 -0
  62. package/build/components/Timeline/TimelineItem.js +235 -0
  63. package/build/components/Timeline/index.d.ts +3 -0
  64. package/build/components/Timeline/index.js +2 -0
  65. package/build/components/index.d.ts +5 -0
  66. package/build/components/index.js +5 -0
  67. package/build/tokens/components/dark/timeline.d.ts +2 -2
  68. package/build/tokens/components/dark/timeline.js +2 -2
  69. package/docs/components/AllComponents.web.tsx +106 -23
  70. package/docs/llm-docs/unistyles-llms-full.txt +1132 -534
  71. package/docs/llm-docs/unistyles-llms-small.txt +37 -37
  72. package/package.json +2 -2
  73. package/src/components/Combobox/Combobox.context.ts +26 -0
  74. package/src/components/Combobox/Combobox.docs.mdx +277 -0
  75. package/src/components/Combobox/Combobox.figma.tsx +60 -0
  76. package/src/components/Combobox/Combobox.props.ts +187 -0
  77. package/src/components/Combobox/Combobox.stories.tsx +233 -0
  78. package/src/components/Combobox/Combobox.tsx +446 -0
  79. package/src/components/Combobox/ComboboxOption.tsx +100 -0
  80. package/src/components/Combobox/index.ts +9 -0
  81. package/src/components/Modal/Modal.tsx +52 -74
  82. package/src/components/Modal/Modal.web.tsx +3 -3
  83. package/src/components/Pagination/Pagination.docs.mdx +99 -0
  84. package/src/components/Pagination/Pagination.figma.tsx +20 -0
  85. package/src/components/Pagination/Pagination.props.ts +28 -0
  86. package/src/components/Pagination/Pagination.stories.tsx +88 -0
  87. package/src/components/Pagination/Pagination.tsx +248 -0
  88. package/src/components/Pagination/Pagination.utils.test.ts +20 -0
  89. package/src/components/Pagination/Pagination.utils.ts +37 -0
  90. package/src/components/Pagination/index.ts +2 -0
  91. package/src/components/SafeAreaView/SafeAreaView.props.ts +20 -0
  92. package/src/components/SafeAreaView/SafeAreaView.tsx +173 -0
  93. package/src/components/SafeAreaView/index.ts +2 -0
  94. package/src/components/Select/Select.tsx +30 -27
  95. package/src/components/Table/Table.context.tsx +23 -0
  96. package/src/components/Table/Table.docs.mdx +239 -0
  97. package/src/components/Table/Table.figma.tsx +65 -0
  98. package/src/components/Table/Table.props.ts +65 -0
  99. package/src/components/Table/Table.stories.tsx +399 -0
  100. package/src/components/Table/Table.tsx +127 -0
  101. package/src/components/Table/Table.utils.test.ts +82 -0
  102. package/src/components/Table/Table.utils.ts +72 -0
  103. package/src/components/Table/TableBody.tsx +25 -0
  104. package/src/components/Table/TableCell.tsx +67 -0
  105. package/src/components/Table/TableHeader.tsx +41 -0
  106. package/src/components/Table/TableHeaderCell.tsx +136 -0
  107. package/src/components/Table/TablePagination.tsx +10 -0
  108. package/src/components/Table/TableRow.tsx +42 -0
  109. package/src/components/Table/index.ts +16 -0
  110. package/src/components/Timeline/Timeline.docs.mdx +177 -0
  111. package/src/components/Timeline/Timeline.figma.tsx +89 -0
  112. package/src/components/Timeline/Timeline.props.ts +51 -0
  113. package/src/components/Timeline/Timeline.stories.tsx +102 -0
  114. package/src/components/Timeline/Timeline.tsx +48 -0
  115. package/src/components/Timeline/TimelineItem.tsx +293 -0
  116. package/src/components/Timeline/index.ts +9 -0
  117. package/src/components/index.ts +5 -0
  118. package/src/tokens/components/dark/timeline.ts +2 -2
@@ -0,0 +1,399 @@
1
+ import { Meta, StoryObj } from '@storybook/react-native';
2
+ import { ExpandSmallIcon } from '@utilitywarehouse/hearth-react-native-icons';
3
+ import { useState } from 'react';
4
+ import { Box } from '../Box';
5
+ import { Flex } from '../Flex';
6
+ import { UnstyledIconButton } from '../UnstyledIconButton';
7
+ import {
8
+ Table,
9
+ TableBody,
10
+ TableCell,
11
+ TableHeader,
12
+ TableHeaderCell,
13
+ TablePagination,
14
+ TableRow,
15
+ } from './';
16
+
17
+ type SortDirection = 'asc' | 'desc';
18
+ type SortStatus = 'Active' | 'Pending' | 'Paused' | 'Cancelled';
19
+
20
+ const rows = [
21
+ {
22
+ id: '1',
23
+ name: 'Alex Morgan',
24
+ email: 'alex@example.com',
25
+ phone: '020 7946 0931',
26
+ city: 'London',
27
+ },
28
+ {
29
+ id: '2',
30
+ name: 'Priya Shah',
31
+ email: 'priya@example.com',
32
+ phone: '0161 496 0124',
33
+ city: 'Manchester',
34
+ },
35
+ {
36
+ id: '3',
37
+ name: 'Chris Brown',
38
+ email: 'chris@example.com',
39
+ phone: '0117 496 1820',
40
+ city: 'Bristol',
41
+ },
42
+ {
43
+ id: '4',
44
+ name: 'Nina Evans',
45
+ email: 'nina@example.com',
46
+ phone: '0141 496 2048',
47
+ city: 'Glasgow',
48
+ },
49
+ {
50
+ id: '5',
51
+ name: 'Luca Rossi',
52
+ email: 'luca@example.com',
53
+ phone: '029 2199 4412',
54
+ city: 'Cardiff',
55
+ },
56
+ {
57
+ id: '6',
58
+ name: 'Sarah Kent',
59
+ email: 'sarah@example.com',
60
+ phone: '0131 496 2755',
61
+ city: 'Edinburgh',
62
+ },
63
+ {
64
+ id: '7',
65
+ name: 'Tom Hall',
66
+ email: 'tom@example.com',
67
+ phone: '0121 496 3901',
68
+ city: 'Birmingham',
69
+ },
70
+ { id: '8', name: 'Emma Dale', email: 'emma@example.com', phone: '0113 496 1140', city: 'Leeds' },
71
+ {
72
+ id: '9',
73
+ name: 'James Cole',
74
+ email: 'james@example.com',
75
+ phone: '0191 496 4502',
76
+ city: 'Newcastle',
77
+ },
78
+ {
79
+ id: '10',
80
+ name: 'Mia White',
81
+ email: 'mia@example.com',
82
+ phone: '0151 496 5620',
83
+ city: 'Liverpool',
84
+ },
85
+ ];
86
+
87
+ const sortableRows = [
88
+ { id: '1', customer: 'Alex Morgan', plan: 'Full Fibre 900', status: 'Pending' as SortStatus },
89
+ { id: '2', customer: 'Priya Shah', plan: 'Energy Saver', status: 'Active' as SortStatus },
90
+ { id: '3', customer: 'Chris Brown', plan: 'Mobile Unlimited', status: 'Paused' as SortStatus },
91
+ { id: '4', customer: 'Nina Evans', plan: 'Home Insurance', status: 'Cancelled' as SortStatus },
92
+ ];
93
+
94
+ const statusOrder = {
95
+ Active: 0,
96
+ Pending: 1,
97
+ Paused: 2,
98
+ Cancelled: 3,
99
+ } as const;
100
+
101
+ const sortRowsByStatus = (items: typeof sortableRows, direction: SortDirection = 'asc') => {
102
+ const multiplier = direction === 'asc' ? 1 : -1;
103
+
104
+ return [...items].sort((left, right) => {
105
+ const statusDifference = (statusOrder[left.status] - statusOrder[right.status]) * multiplier;
106
+
107
+ if (statusDifference !== 0) {
108
+ return statusDifference;
109
+ }
110
+
111
+ return left.customer.localeCompare(right.customer) * multiplier;
112
+ });
113
+ };
114
+
115
+ const meta = {
116
+ title: 'Stories / Table',
117
+ component: Table,
118
+ args: {
119
+ container: 'subtle',
120
+ },
121
+ argTypes: {
122
+ container: {
123
+ control: 'radio',
124
+ options: ['none', 'subtle', 'emphasis'],
125
+ },
126
+ pagination: {
127
+ control: false,
128
+ },
129
+ children: {
130
+ control: false,
131
+ },
132
+ },
133
+ } satisfies Meta<typeof Table>;
134
+
135
+ export default meta;
136
+ type Story = StoryObj<typeof meta>;
137
+
138
+ const HeaderSortButton = ({ inverted, label }: { inverted: boolean; label: string }) => (
139
+ <UnstyledIconButton
140
+ accessibilityLabel={label}
141
+ icon={ExpandSmallIcon}
142
+ inverted={inverted}
143
+ size="sm"
144
+ />
145
+ );
146
+
147
+ export const Playground: Story = {
148
+ render: (args: StoryObj<typeof meta.args>) => {
149
+ const headerColor = args.container === 'none' ? 'white' : 'purple';
150
+ const headerInverted = headerColor === 'purple';
151
+
152
+ return (
153
+ <Table {...args}>
154
+ <TableHeader color={headerColor}>
155
+ <TableHeaderCell
156
+ trailingContent={<HeaderSortButton inverted={headerInverted} label="Sort by name" />}
157
+ >
158
+ Name
159
+ </TableHeaderCell>
160
+ <TableHeaderCell
161
+ trailingContent={<HeaderSortButton inverted={headerInverted} label="Sort by email" />}
162
+ >
163
+ Email
164
+ </TableHeaderCell>
165
+ <TableHeaderCell
166
+ trailingContent={<HeaderSortButton inverted={headerInverted} label="Sort by phone" />}
167
+ >
168
+ Phone
169
+ </TableHeaderCell>
170
+ <TableHeaderCell
171
+ trailingContent={<HeaderSortButton inverted={headerInverted} label="Sort by city" />}
172
+ >
173
+ City
174
+ </TableHeaderCell>
175
+ </TableHeader>
176
+ <TableBody>
177
+ {rows.slice(0, 5).map(person => (
178
+ <TableRow key={person.id}>
179
+ <TableHeaderCell row>{person.name}</TableHeaderCell>
180
+ <TableCell>{person.email}</TableCell>
181
+ <TableCell>{person.phone}</TableCell>
182
+ <TableCell>{person.city}</TableCell>
183
+ </TableRow>
184
+ ))}
185
+ </TableBody>
186
+ </Table>
187
+ );
188
+ },
189
+ };
190
+
191
+ export const Variants: Story = {
192
+ render: () => (
193
+ <Flex direction="column" spacing="xl" style={{ width: '100%' }}>
194
+ {[
195
+ { container: 'none' as const, color: 'white' as const },
196
+ { container: 'subtle' as const, color: 'purple' as const },
197
+ { container: 'emphasis' as const, color: 'purple' as const },
198
+ ].map(({ container, color }) => (
199
+ <Table key={`${container}-${color}`} container={container}>
200
+ <TableHeader color={color}>
201
+ <TableHeaderCell
202
+ trailingContent={
203
+ <HeaderSortButton inverted={color === 'purple'} label="Sort by name" />
204
+ }
205
+ >
206
+ Name
207
+ </TableHeaderCell>
208
+ <TableHeaderCell
209
+ trailingContent={
210
+ <HeaderSortButton inverted={color === 'purple'} label="Sort by plan" />
211
+ }
212
+ >
213
+ Plan
214
+ </TableHeaderCell>
215
+ <TableHeaderCell
216
+ trailingContent={
217
+ <HeaderSortButton inverted={color === 'purple'} label="Sort by status" />
218
+ }
219
+ >
220
+ Status
221
+ </TableHeaderCell>
222
+ </TableHeader>
223
+ <TableBody>
224
+ <TableRow>
225
+ <TableHeaderCell row>Alex Morgan</TableHeaderCell>
226
+ <TableCell>Full Fibre 900</TableCell>
227
+ <TableCell>Active</TableCell>
228
+ </TableRow>
229
+ <TableRow>
230
+ <TableHeaderCell row>Priya Shah</TableHeaderCell>
231
+ <TableCell>Energy Saver</TableCell>
232
+ <TableCell>Pending</TableCell>
233
+ </TableRow>
234
+ </TableBody>
235
+ </Table>
236
+ ))}
237
+ </Flex>
238
+ ),
239
+ };
240
+
241
+ export const WithPagination: Story = {
242
+ render: (args: StoryObj<typeof meta.args>) => {
243
+ const [currentPage, setCurrentPage] = useState(1);
244
+ const itemsPerPage = 4;
245
+ const totalPages = Math.ceil(rows.length / itemsPerPage);
246
+ const pageRows = rows.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
247
+ const headerColor = args.container === 'none' ? 'white' : 'purple';
248
+ const headerInverted = headerColor === 'purple';
249
+
250
+ return (
251
+ <Table
252
+ {...args}
253
+ pagination={
254
+ <TablePagination
255
+ currentPage={currentPage}
256
+ onPageChange={setCurrentPage}
257
+ totalPages={totalPages}
258
+ />
259
+ }
260
+ >
261
+ <TableHeader color={headerColor}>
262
+ <TableHeaderCell
263
+ trailingContent={<HeaderSortButton inverted={headerInverted} label="Sort by name" />}
264
+ >
265
+ Name
266
+ </TableHeaderCell>
267
+ <TableHeaderCell
268
+ trailingContent={<HeaderSortButton inverted={headerInverted} label="Sort by email" />}
269
+ >
270
+ Email
271
+ </TableHeaderCell>
272
+ <TableHeaderCell
273
+ trailingContent={<HeaderSortButton inverted={headerInverted} label="Sort by city" />}
274
+ >
275
+ City
276
+ </TableHeaderCell>
277
+ </TableHeader>
278
+ <TableBody>
279
+ {pageRows.map(person => (
280
+ <TableRow key={person.id}>
281
+ <TableHeaderCell row>{person.name}</TableHeaderCell>
282
+ <TableCell>{person.email}</TableCell>
283
+ <TableCell>{person.city}</TableCell>
284
+ </TableRow>
285
+ ))}
286
+ </TableBody>
287
+ </Table>
288
+ );
289
+ },
290
+ };
291
+
292
+ export const NarrowViewport: Story = {
293
+ render: () => (
294
+ <Box style={{ width: 320 }}>
295
+ <Table
296
+ container="subtle"
297
+ pagination={
298
+ <TablePagination condensed currentPage={1} onPageChange={() => {}} totalPages={10} />
299
+ }
300
+ >
301
+ <TableHeader color="purple">
302
+ <TableHeaderCell trailingContent={<HeaderSortButton inverted label="Sort by account" />}>
303
+ Account
304
+ </TableHeaderCell>
305
+ <TableHeaderCell trailingContent={<HeaderSortButton inverted label="Sort by type" />}>
306
+ Type
307
+ </TableHeaderCell>
308
+ <TableHeaderCell
309
+ trailingContent={<HeaderSortButton inverted label="Sort by monthly cost" />}
310
+ >
311
+ Monthly cost
312
+ </TableHeaderCell>
313
+ <TableHeaderCell trailingContent={<HeaderSortButton inverted label="Sort by renewal" />}>
314
+ Renewal
315
+ </TableHeaderCell>
316
+ </TableHeader>
317
+ <TableBody>
318
+ <TableRow>
319
+ <TableHeaderCell row>Energy</TableHeaderCell>
320
+ <TableCell>Dual fuel</TableCell>
321
+ <TableCell>£132.50</TableCell>
322
+ <TableCell>12 Sep</TableCell>
323
+ </TableRow>
324
+ <TableRow>
325
+ <TableHeaderCell row>Broadband</TableHeaderCell>
326
+ <TableCell>Full Fibre 900</TableCell>
327
+ <TableCell>£44.00</TableCell>
328
+ <TableCell>22 Nov</TableCell>
329
+ </TableRow>
330
+ </TableBody>
331
+ </Table>
332
+ </Box>
333
+ ),
334
+ };
335
+
336
+ export const ConfiguredColumnWidths: Story = {
337
+ render: () => (
338
+ <Table columnWidths={[180, '2fr', '1fr', 140]} container="subtle">
339
+ <TableHeader color="purple">
340
+ <TableHeaderCell>Name</TableHeaderCell>
341
+ <TableHeaderCell>Email</TableHeaderCell>
342
+ <TableHeaderCell>Plan</TableHeaderCell>
343
+ <TableHeaderCell>Status</TableHeaderCell>
344
+ </TableHeader>
345
+ <TableBody>
346
+ <TableRow>
347
+ <TableHeaderCell row>Alex Morgan</TableHeaderCell>
348
+ <TableCell>alex.longer-email@example.com</TableCell>
349
+ <TableCell>Full Fibre 900</TableCell>
350
+ <TableCell>Active</TableCell>
351
+ </TableRow>
352
+ <TableRow>
353
+ <TableHeaderCell row>Priya Shah</TableHeaderCell>
354
+ <TableCell>priya@example.com</TableCell>
355
+ <TableCell>Energy Saver</TableCell>
356
+ <TableCell>Pending</TableCell>
357
+ </TableRow>
358
+ </TableBody>
359
+ </Table>
360
+ ),
361
+ };
362
+
363
+ export const WithCustomSortFunction: Story = {
364
+ render: () => {
365
+ const [direction, setDirection] = useState<SortDirection>('asc');
366
+ const sortedRows = sortRowsByStatus(sortableRows, direction);
367
+
368
+ return (
369
+ <Table columnWidths={[180, '2fr', 120]} container="subtle">
370
+ <TableHeader color="purple">
371
+ <TableHeaderCell>Customer</TableHeaderCell>
372
+ <TableHeaderCell>Plan</TableHeaderCell>
373
+ <TableHeaderCell
374
+ trailingContent={
375
+ <UnstyledIconButton
376
+ accessibilityLabel="Sort by custom status order"
377
+ icon={ExpandSmallIcon}
378
+ inverted
379
+ onPress={() => setDirection(current => (current === 'asc' ? 'desc' : 'asc'))}
380
+ size="sm"
381
+ />
382
+ }
383
+ >
384
+ Status
385
+ </TableHeaderCell>
386
+ </TableHeader>
387
+ <TableBody>
388
+ {sortedRows.map(item => (
389
+ <TableRow key={item.id}>
390
+ <TableHeaderCell row>{item.customer}</TableHeaderCell>
391
+ <TableCell>{item.plan}</TableCell>
392
+ <TableCell>{item.status}</TableCell>
393
+ </TableRow>
394
+ ))}
395
+ </TableBody>
396
+ </Table>
397
+ );
398
+ },
399
+ };
@@ -0,0 +1,127 @@
1
+ import { Children, isValidElement, useState } from 'react';
2
+ import { type LayoutChangeEvent, View } from 'react-native';
3
+ import { ScrollView } from 'react-native-gesture-handler';
4
+ import { StyleSheet } from 'react-native-unistyles';
5
+ import { Card } from '../Card';
6
+ import { TableContextProvider } from './Table.context';
7
+ import type { TableProps } from './Table.props';
8
+ import { getMinTableWidth } from './Table.utils';
9
+
10
+ const MIN_COLUMN_WIDTH = 120;
11
+
12
+ const getChildCount = (node: React.ReactNode): number => {
13
+ if (!isValidElement(node)) {
14
+ return 0;
15
+ }
16
+
17
+ const childProps = node.props as { children?: React.ReactNode };
18
+ return Children.count(childProps.children);
19
+ };
20
+
21
+ const getMaxColumnCount = (children: React.ReactNode): number => {
22
+ let maxColumns = 1;
23
+
24
+ Children.forEach(children, child => {
25
+ if (!isValidElement(child)) {
26
+ return;
27
+ }
28
+
29
+ const childType = child.type as { displayName?: string };
30
+ const childProps = child.props as { children?: React.ReactNode };
31
+
32
+ if (childType.displayName === 'TableHeader') {
33
+ maxColumns = Math.max(maxColumns, getChildCount(child));
34
+ return;
35
+ }
36
+
37
+ if (childType.displayName === 'TableBody') {
38
+ Children.forEach(childProps.children, rowChild => {
39
+ maxColumns = Math.max(maxColumns, getChildCount(rowChild));
40
+ });
41
+ }
42
+ });
43
+
44
+ return maxColumns;
45
+ };
46
+
47
+ const Table = ({
48
+ children,
49
+ columnWidths = [],
50
+ container = 'none',
51
+ pagination,
52
+ style,
53
+ ...props
54
+ }: TableProps) => {
55
+ const [containerWidth, setContainerWidth] = useState(0);
56
+ const columnCount = getMaxColumnCount(children);
57
+ const minTableWidth = getMinTableWidth(columnCount, columnWidths, MIN_COLUMN_WIDTH);
58
+ const tableWidth = Math.max(containerWidth, minTableWidth);
59
+ const isScrollable = tableWidth > containerWidth + 1;
60
+
61
+ const handleLayout = (event: LayoutChangeEvent) => {
62
+ const nextWidth = event.nativeEvent.layout.width;
63
+ setContainerWidth(currentWidth => (currentWidth === nextWidth ? currentWidth : nextWidth));
64
+ };
65
+
66
+ const content = (
67
+ <View onLayout={handleLayout} style={styles.wrapper}>
68
+ <ScrollView
69
+ alwaysBounceHorizontal={false}
70
+ alwaysBounceVertical={false}
71
+ bounces={false}
72
+ canCancelContentTouches
73
+ contentContainerStyle={styles.scrollContent}
74
+ horizontal
75
+ keyboardShouldPersistTaps="handled"
76
+ nestedScrollEnabled
77
+ overScrollMode="never"
78
+ scrollEnabled={isScrollable}
79
+ scrollEventThrottle={16}
80
+ showsHorizontalScrollIndicator
81
+ >
82
+ <View style={[styles.content, { width: tableWidth || minTableWidth }]}>{children}</View>
83
+ </ScrollView>
84
+ {pagination ? <View style={styles.pagination}>{pagination}</View> : null}
85
+ </View>
86
+ );
87
+
88
+ const hasPagination = Boolean(pagination);
89
+
90
+ return (
91
+ <TableContextProvider value={{ columnCount, columnWidths, container, hasPagination }}>
92
+ {container !== 'none' ? (
93
+ <Card {...props} colorScheme="neutralStrong" noPadding style={style} variant={container}>
94
+ {content}
95
+ </Card>
96
+ ) : (
97
+ <View {...props} style={[styles.plainTable, style]}>
98
+ {content}
99
+ </View>
100
+ )}
101
+ </TableContextProvider>
102
+ );
103
+ };
104
+
105
+ Table.displayName = 'Table';
106
+
107
+ const styles = StyleSheet.create(theme => ({
108
+ wrapper: {
109
+ width: '100%',
110
+ },
111
+ plainTable: {
112
+ width: '100%',
113
+ overflow: 'hidden',
114
+ },
115
+ scrollContent: {
116
+ minWidth: '100%',
117
+ },
118
+ content: {
119
+ alignSelf: 'flex-start',
120
+ },
121
+ pagination: {
122
+ paddingHorizontal: theme.components.table.cell.padding,
123
+ paddingVertical: theme.space[200],
124
+ },
125
+ }));
126
+
127
+ export default Table;
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getColumnMinWidth, getColumnStyle, getMinTableWidth } from './Table.utils';
3
+
4
+ describe('getColumnMinWidth', () => {
5
+ it('returns the configured fixed width when it is larger than the minimum', () => {
6
+ expect(getColumnMinWidth(180, 120)).toBe(180);
7
+ });
8
+
9
+ it('clamps fixed widths to the minimum column width', () => {
10
+ expect(getColumnMinWidth(80, 120)).toBe(120);
11
+ });
12
+
13
+ it('returns the minimum width for flexible and unspecified columns', () => {
14
+ expect(getColumnMinWidth('2fr', 120)).toBe(120);
15
+ expect(getColumnMinWidth({ flex: 3 }, 120)).toBe(120);
16
+ expect(getColumnMinWidth(undefined, 120)).toBe(120);
17
+ });
18
+ });
19
+
20
+ describe('getMinTableWidth', () => {
21
+ it('adds fixed widths and minimum widths for flexible columns', () => {
22
+ expect(getMinTableWidth(4, [180, '2fr', { flex: 3 }], 120)).toBe(540);
23
+ });
24
+
25
+ it('uses the minimum width for unspecified trailing columns', () => {
26
+ expect(getMinTableWidth(3, [200], 120)).toBe(440);
27
+ });
28
+ });
29
+
30
+ describe('getColumnStyle', () => {
31
+ it('returns a fixed-width style for numeric column widths', () => {
32
+ expect(getColumnStyle(180, 120)).toEqual({
33
+ width: 180,
34
+ minWidth: 180,
35
+ flexGrow: 0,
36
+ flexShrink: 0,
37
+ });
38
+ });
39
+
40
+ it('maps fr values to flexGrow', () => {
41
+ expect(getColumnStyle('2fr', 120)).toEqual({
42
+ minWidth: 120,
43
+ flexBasis: 0,
44
+ flexGrow: 2,
45
+ flexShrink: 0,
46
+ });
47
+ });
48
+
49
+ it('supports fractional fr values', () => {
50
+ expect(getColumnStyle('0.5fr', 120)).toEqual({
51
+ minWidth: 120,
52
+ flexBasis: 0,
53
+ flexGrow: 0.5,
54
+ flexShrink: 0,
55
+ });
56
+ });
57
+
58
+ it('uses flex weights from object column widths', () => {
59
+ expect(getColumnStyle({ flex: 3 }, 120)).toEqual({
60
+ minWidth: 120,
61
+ flexBasis: 0,
62
+ flexGrow: 3,
63
+ flexShrink: 0,
64
+ });
65
+ });
66
+
67
+ it('falls back to a single flexible column for invalid flexible values', () => {
68
+ expect(getColumnStyle('invalid' as `${number}fr`, 120)).toEqual({
69
+ minWidth: 120,
70
+ flexBasis: 0,
71
+ flexGrow: 1,
72
+ flexShrink: 0,
73
+ });
74
+
75
+ expect(getColumnStyle({ flex: 0 }, 120)).toEqual({
76
+ minWidth: 120,
77
+ flexBasis: 0,
78
+ flexGrow: 1,
79
+ flexShrink: 0,
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,72 @@
1
+ import type { ViewStyle } from 'react-native';
2
+ import type { TableColumnWidth } from './Table.props';
3
+
4
+ const FR_UNIT_PATTERN = /^(\d*\.?\d+)fr$/;
5
+
6
+ const parseFractionUnit = (columnWidth?: TableColumnWidth): number | null => {
7
+ if (typeof columnWidth !== 'string') {
8
+ return null;
9
+ }
10
+
11
+ const match = columnWidth.match(FR_UNIT_PATTERN);
12
+ if (!match) {
13
+ return null;
14
+ }
15
+
16
+ const parsedValue = Number(match[1]);
17
+ return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : null;
18
+ };
19
+
20
+ const getFlexGrow = (columnWidth?: TableColumnWidth): number => {
21
+ if (!columnWidth || typeof columnWidth === 'number') {
22
+ return 1;
23
+ }
24
+
25
+ if (typeof columnWidth === 'string') {
26
+ return parseFractionUnit(columnWidth) ?? 1;
27
+ }
28
+
29
+ return Number.isFinite(columnWidth.flex) && columnWidth.flex > 0 ? columnWidth.flex : 1;
30
+ };
31
+
32
+ export const getColumnMinWidth = (
33
+ columnWidth: TableColumnWidth | undefined,
34
+ minColumnWidth: number
35
+ ): number => {
36
+ if (typeof columnWidth === 'number') {
37
+ return Math.max(columnWidth, minColumnWidth);
38
+ }
39
+
40
+ return minColumnWidth;
41
+ };
42
+
43
+ export const getMinTableWidth = (
44
+ columnCount: number,
45
+ columnWidths: TableColumnWidth[],
46
+ minColumnWidth: number
47
+ ): number => {
48
+ return Array.from({ length: columnCount }).reduce<number>((totalWidth, _, index) => {
49
+ return totalWidth + getColumnMinWidth(columnWidths[index], minColumnWidth);
50
+ }, 0);
51
+ };
52
+
53
+ export const getColumnStyle = (
54
+ columnWidth: TableColumnWidth | undefined,
55
+ minColumnWidth: number
56
+ ): ViewStyle => {
57
+ if (typeof columnWidth === 'number') {
58
+ return {
59
+ width: columnWidth,
60
+ minWidth: columnWidth,
61
+ flexGrow: 0,
62
+ flexShrink: 0,
63
+ };
64
+ }
65
+
66
+ return {
67
+ minWidth: minColumnWidth,
68
+ flexBasis: 0,
69
+ flexGrow: getFlexGrow(columnWidth),
70
+ flexShrink: 0,
71
+ };
72
+ };
@@ -0,0 +1,25 @@
1
+ import { Children, cloneElement, isValidElement } from 'react';
2
+ import { View } from 'react-native';
3
+ import { TableBodyProps } from './Table.props';
4
+
5
+ const TableBody = ({ children, ...props }: TableBodyProps) => {
6
+ const items = Children.toArray(children);
7
+
8
+ return (
9
+ <View {...props}>
10
+ {items.map((child, index) => {
11
+ if (!isValidElement(child)) {
12
+ return child;
13
+ }
14
+
15
+ return cloneElement(child as React.ReactElement<{ isLastRow?: boolean }>, {
16
+ isLastRow: index === items.length - 1,
17
+ });
18
+ })}
19
+ </View>
20
+ );
21
+ };
22
+
23
+ TableBody.displayName = 'TableBody';
24
+
25
+ export default TableBody;