panelui-native 0.30.0 → 0.32.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 (61) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/calendar/index.js +107 -24
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/heatmap-chart/index.js +9 -8
  5. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  6. package/lib/module/components/map/index.js +50 -4
  7. package/lib/module/components/map/index.js.map +1 -1
  8. package/lib/module/components/plan/index.js +307 -21
  9. package/lib/module/components/plan/index.js.map +1 -1
  10. package/lib/module/components/post/index.js +992 -0
  11. package/lib/module/components/post/index.js.map +1 -0
  12. package/lib/module/components/response/index.js +296 -0
  13. package/lib/module/components/response/index.js.map +1 -0
  14. package/lib/module/components/response/markdown.js +463 -0
  15. package/lib/module/components/response/markdown.js.map +1 -0
  16. package/lib/module/components/select/index.js +97 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/table/index.js +632 -0
  19. package/lib/module/components/table/index.js.map +1 -0
  20. package/lib/module/icons/index.js +200 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +4 -1
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/utils/date.js +129 -22
  25. package/lib/module/utils/date.js.map +1 -1
  26. package/lib/typescript/src/components/calendar/index.d.ts +12 -3
  27. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/map/index.d.ts +8 -1
  30. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/plan/index.d.ts +121 -10
  32. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/post/index.d.ts +358 -0
  34. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/response/index.d.ts +91 -0
  36. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  38. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  39. package/lib/typescript/src/components/select/index.d.ts +27 -2
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/table/index.d.ts +274 -0
  42. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  43. package/lib/typescript/src/icons/index.d.ts +40 -1
  44. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  45. package/lib/typescript/src/index.d.ts +5 -2
  46. package/lib/typescript/src/index.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/date.d.ts +49 -5
  48. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/calendar/index.tsx +136 -23
  51. package/src/components/heatmap-chart/index.tsx +9 -9
  52. package/src/components/map/index.tsx +53 -4
  53. package/src/components/plan/index.tsx +344 -21
  54. package/src/components/post/index.tsx +1116 -0
  55. package/src/components/response/index.tsx +390 -0
  56. package/src/components/response/markdown.ts +489 -0
  57. package/src/components/select/index.tsx +132 -12
  58. package/src/components/table/index.tsx +747 -0
  59. package/src/icons/index.tsx +162 -1
  60. package/src/index.ts +60 -0
  61. package/src/utils/date.ts +146 -22
@@ -0,0 +1,747 @@
1
+ /**
2
+ * Table — rows and columns that stay lined up.
3
+ *
4
+ * React Native has no table layout: no `<table>`, no column model, nothing that
5
+ * makes the third cell of one row the same width as the third cell of the next.
6
+ * A table here is therefore a stack of flex rows, and the columns exist only
7
+ * because every row divides its width the same way. That is the one rule the
8
+ * component cannot enforce for you — `flex` and `width` on a `Table.Head` and
9
+ * on the `Table.Cell` beneath it have to agree, or the column drifts.
10
+ *
11
+ * Everything else it does own: the hairlines between rows and the missing one
12
+ * under the last, the muted header and footer, the striping, the alignment of a
13
+ * numeric column, and the sort arrow that turns over rather than swapping.
14
+ *
15
+ * ```tsx
16
+ * <Table variant="outline">
17
+ * <Table.Header>
18
+ * <Table.Row>
19
+ * <Table.Head flex={2}>Invoice</Table.Head>
20
+ * <Table.Head align="end" sortDirection="desc" onPress={toggleSort}>
21
+ * Amount
22
+ * </Table.Head>
23
+ * </Table.Row>
24
+ * </Table.Header>
25
+ * <Table.Body>
26
+ * <Table.Row>
27
+ * <Table.Cell flex={2}>INV-001</Table.Cell>
28
+ * <Table.Cell align="end">$250.00</Table.Cell>
29
+ * </Table.Row>
30
+ * </Table.Body>
31
+ * </Table>
32
+ * ```
33
+ *
34
+ * `Table.Frame` is the same table in a widget shell, with the column headings
35
+ * lifted onto the tray above the card. It takes the whole table and does the
36
+ * lift itself, so the columns are still declared once.
37
+ *
38
+ * A table wider than the phone belongs in a horizontal scroller with a
39
+ * `minWidth` on the table, not squeezed until the columns are unreadable — wrap
40
+ * it in `ScrollFade` and the cut edge tells you there is more to the right.
41
+ *
42
+ * Long tables belong in a `FlatList` rather than in `Table.Body`, which renders
43
+ * every row it is given. `Table.Row` takes `index` and `last` directly for that
44
+ * case, since a virtualised row has no parent to read them from.
45
+ */
46
+ import {
47
+ Children,
48
+ cloneElement,
49
+ createContext,
50
+ forwardRef,
51
+ isValidElement,
52
+ useContext,
53
+ useEffect,
54
+ useMemo,
55
+ type ReactElement,
56
+ type ReactNode,
57
+ } from 'react';
58
+ import { View, type ViewProps } from 'react-native';
59
+ import Animated, {
60
+ useAnimatedStyle,
61
+ useSharedValue,
62
+ withTiming,
63
+ } from 'react-native-reanimated';
64
+ import { tv, type VariantProps } from 'tailwind-variants';
65
+ import { useCSSVariable } from 'uniwind';
66
+ import { ChevronUpIcon } from '../../icons';
67
+ import {
68
+ AnimatedPressable,
69
+ type AnimatedPressableProps,
70
+ } from '../../primitives/animated-pressable';
71
+ import { Text, type TextProps, textChildren } from '../../primitives/text';
72
+ import { cn } from '../../utils/cn';
73
+ import { selectionTick } from '../../utils/haptics';
74
+ import { Frame } from '../frame';
75
+
76
+ /** Long enough to read as the arrow turning over, short enough not to lag a tap. */
77
+ const SORT_DURATION = 160;
78
+
79
+ type TableSize = 'default' | 'sm';
80
+ type TableSection = 'header' | 'body' | 'footer';
81
+ type CellAlign = 'start' | 'center' | 'end';
82
+ export type TableSortDirection = 'asc' | 'desc';
83
+
84
+ const tableVariants = tv({
85
+ slots: {
86
+ root: 'w-full',
87
+ row: 'w-full flex-row items-center',
88
+ head: 'flex-row items-center gap-1.5',
89
+ headLabel: 'font-medium text-muted-foreground',
90
+ cell: 'flex-row items-center',
91
+ cellLabel: 'text-foreground',
92
+ caption: 'text-muted-foreground',
93
+ },
94
+ variants: {
95
+ variant: {
96
+ /** Hairlines only — the table sits directly on the page. */
97
+ default: {},
98
+ /** Framed and clipped, for a table that reads as its own card. */
99
+ outline: { root: 'overflow-hidden rounded-xl border border-border' },
100
+ },
101
+ size: {
102
+ default: {
103
+ row: 'min-h-12 gap-3 px-4',
104
+ headLabel: 'text-xs',
105
+ cellLabel: 'text-sm',
106
+ caption: 'text-sm',
107
+ },
108
+ sm: {
109
+ row: 'min-h-10 gap-2 px-3',
110
+ headLabel: 'text-[11px]',
111
+ cellLabel: 'text-xs',
112
+ caption: 'text-xs',
113
+ },
114
+ },
115
+ },
116
+ defaultVariants: {
117
+ variant: 'default',
118
+ size: 'default',
119
+ },
120
+ });
121
+
122
+ const alignment: Record<CellAlign, string> = {
123
+ start: 'justify-start',
124
+ center: 'justify-center',
125
+ end: 'justify-end',
126
+ };
127
+
128
+ /**
129
+ * Density and striping are set once on the root; every part below reads them
130
+ * from here rather than being told again per row and per cell.
131
+ */
132
+ const TableContext = createContext<{ size: TableSize; striped: boolean }>({
133
+ size: 'default',
134
+ striped: false,
135
+ });
136
+
137
+ /**
138
+ * Which band of the table a row is in, and where in that band. A row needs all
139
+ * three: the section decides its text and its borders, the index decides
140
+ * whether it is striped, and being last is what removes the hairline that would
141
+ * otherwise double up with the table's own bottom edge.
142
+ */
143
+ const TableRowContext = createContext<{
144
+ section: TableSection;
145
+ index: number;
146
+ last: boolean;
147
+ }>({
148
+ section: 'body',
149
+ index: 0,
150
+ last: false,
151
+ });
152
+
153
+ export interface TableProps extends ViewProps, VariantProps<typeof tableVariants> {
154
+ className?: string;
155
+ /**
156
+ * Row density. `Table.Row`, `Table.Head` and `Table.Cell` follow it, so it
157
+ * only needs setting here.
158
+ */
159
+ size?: TableSize;
160
+ /**
161
+ * Tint every other body row. Helps the eye track across a wide row; drop it
162
+ * for a short table, where the stripes are louder than the data.
163
+ */
164
+ striped?: boolean;
165
+ children?: ReactNode;
166
+ }
167
+
168
+ const TableRoot = forwardRef<View, TableProps>(
169
+ ({ className, variant, size = 'default', striped = false, children, ...props }, ref) => {
170
+ const { root } = tableVariants({ variant, size });
171
+ const context = useMemo(() => ({ size, striped }), [size, striped]);
172
+
173
+ return (
174
+ <TableContext.Provider value={context}>
175
+ <View ref={ref} role="table" className={root({ className })} {...props}>
176
+ {textChildren(children)}
177
+ </View>
178
+ </TableContext.Provider>
179
+ );
180
+ }
181
+ );
182
+ TableRoot.displayName = 'Table';
183
+
184
+ /**
185
+ * Wraps each row of a section in its position, so a row does not have to be
186
+ * told where it sits. Wrapping rather than cloning: a row is often produced by
187
+ * a `.map()` through a component of your own, and props set on that wrapper
188
+ * would never reach the row itself.
189
+ */
190
+ function useSectionRows(children: ReactNode, section: TableSection): ReactNode {
191
+ return useMemo(() => {
192
+ const rows = Children.toArray(children).filter((child) => isValidElement(child));
193
+
194
+ return rows.map((child, index) => (
195
+ <TableRowContext.Provider
196
+ // The row's own key stays on the element; this wrapper needs its own.
197
+ key={index}
198
+ value={{ section, index, last: index === rows.length - 1 }}
199
+ >
200
+ {child}
201
+ </TableRowContext.Provider>
202
+ ));
203
+ }, [children, section]);
204
+ }
205
+
206
+ export interface TableHeaderProps extends ViewProps {
207
+ className?: string;
208
+ children?: ReactNode;
209
+ }
210
+
211
+ /** The band of column headers. Draws the rule that separates it from the body. */
212
+ const TableHeader = forwardRef<View, TableHeaderProps>(
213
+ ({ className, children, ...props }, ref) => {
214
+ const rows = useSectionRows(children, 'header');
215
+
216
+ return (
217
+ <View
218
+ ref={ref}
219
+ role="rowgroup"
220
+ className={cn('w-full border-b border-border', className)}
221
+ {...props}
222
+ >
223
+ {rows}
224
+ </View>
225
+ );
226
+ }
227
+ );
228
+ TableHeader.displayName = 'Table.Header';
229
+
230
+ export interface TableBodyProps extends ViewProps {
231
+ className?: string;
232
+ children?: ReactNode;
233
+ }
234
+
235
+ /** The data rows. Renders every child it is given — see `FlatList` for long tables. */
236
+ const TableBody = forwardRef<View, TableBodyProps>(
237
+ ({ className, children, ...props }, ref) => {
238
+ const rows = useSectionRows(children, 'body');
239
+
240
+ return (
241
+ <View ref={ref} role="rowgroup" className={cn('w-full', className)} {...props}>
242
+ {rows}
243
+ </View>
244
+ );
245
+ }
246
+ );
247
+ TableBody.displayName = 'Table.Body';
248
+
249
+ export interface TableFooterProps extends ViewProps {
250
+ className?: string;
251
+ children?: ReactNode;
252
+ }
253
+
254
+ /** Totals band. Tinted and ruled off, because a sum is not another row of data. */
255
+ const TableFooter = forwardRef<View, TableFooterProps>(
256
+ ({ className, children, ...props }, ref) => {
257
+ const rows = useSectionRows(children, 'footer');
258
+
259
+ return (
260
+ <View
261
+ ref={ref}
262
+ role="rowgroup"
263
+ className={cn('w-full border-t border-border bg-muted/50', className)}
264
+ {...props}
265
+ >
266
+ {rows}
267
+ </View>
268
+ );
269
+ }
270
+ );
271
+ TableFooter.displayName = 'Table.Footer';
272
+
273
+ export interface TableRowProps
274
+ extends Omit<AnimatedPressableProps, 'children' | 'disabled'> {
275
+ className?: string;
276
+ /** Marks the row as the chosen one — for a table you pick from. */
277
+ selected?: boolean;
278
+ disabled?: boolean;
279
+ /**
280
+ * Position in the section, for a row rendered outside `Table.Body` — a
281
+ * `FlatList` item, say. Decides which rows a striped table tints.
282
+ */
283
+ index?: number;
284
+ /**
285
+ * Whether this is the section's final row, for a row rendered outside
286
+ * `Table.Body`. The last row drops its hairline so it does not double up with
287
+ * the table's own bottom edge.
288
+ */
289
+ last?: boolean;
290
+ children?: ReactNode;
291
+ }
292
+
293
+ /**
294
+ * Renders as a pressable when given `onPress`, and as a plain view otherwise,
295
+ * so a table you only read does not announce every row as a button.
296
+ */
297
+ const TableRow = forwardRef<View, TableRowProps>(
298
+ ({ className, selected, disabled, index, last, children, onPress, ...props }, ref) => {
299
+ const { size, striped } = useContext(TableContext);
300
+ const row = useContext(TableRowContext);
301
+ const { row: rowClass } = tableVariants({ size });
302
+
303
+ const position = index ?? row.index;
304
+ const isLast = last ?? row.last;
305
+ // The header is one row above a rule of its own and the footer one below
306
+ // another, so neither wants a hairline; in the body every row but the last
307
+ // is separated from the one under it.
308
+ const ruled = row.section === 'body' && !isLast;
309
+ // Stripe the odd rows, so the first row of a table reads on the table's own
310
+ // background rather than in a band.
311
+ const striping =
312
+ striped && row.section === 'body' && position % 2 === 1 && !selected;
313
+
314
+ const classes = rowClass({
315
+ className: cn(
316
+ ruled && 'border-b border-border',
317
+ striping && 'bg-muted/40',
318
+ selected && 'bg-accent',
319
+ disabled && 'opacity-[0.64]',
320
+ className
321
+ ),
322
+ });
323
+
324
+ if (!onPress) {
325
+ return (
326
+ <View
327
+ ref={ref}
328
+ role="row"
329
+ accessibilityState={{ disabled: !!disabled, selected: !!selected }}
330
+ className={classes}
331
+ {...(props as ViewProps)}
332
+ >
333
+ {textChildren(children)}
334
+ </View>
335
+ );
336
+ }
337
+
338
+ return (
339
+ <AnimatedPressable
340
+ ref={ref}
341
+ // A row you can act on is a button first: announcing it as a row would
342
+ // hide the fact that it does anything.
343
+ accessibilityRole="button"
344
+ accessibilityState={{ disabled: !!disabled, selected: !!selected }}
345
+ disabled={disabled}
346
+ onPress={onPress}
347
+ className={classes}
348
+ {...props}
349
+ >
350
+ {textChildren(children)}
351
+ </AnimatedPressable>
352
+ );
353
+ }
354
+ );
355
+ TableRow.displayName = 'Table.Row';
356
+
357
+ /**
358
+ * Column sizing, shared by a head and the cells beneath it. Written out on both
359
+ * rather than inherited from one place, so the props table on each says what it
360
+ * actually takes.
361
+ */
362
+ function columnStyle({ flex, width }: { flex?: number; width?: number }) {
363
+ if (width !== undefined) return { width, flexGrow: 0, flexShrink: 0 };
364
+ return { flex: flex ?? 1 };
365
+ }
366
+
367
+ export interface TableHeadProps extends Omit<ViewProps, 'children'> {
368
+ className?: string;
369
+ /**
370
+ * Share of the leftover width, relative to the other cells in the row.
371
+ * Defaults to 1, so columns divide the row evenly. Must match the `flex` on
372
+ * the cells beneath it.
373
+ */
374
+ flex?: number;
375
+ /**
376
+ * Fixed width in pixels, for a column that must not move — an icon, a state
377
+ * dot. Must match the `width` on the cells beneath it.
378
+ */
379
+ width?: number;
380
+ /**
381
+ * Which edge the column's content sits against. Use `end` for numbers: a
382
+ * money column reads as a column only when the digits line up.
383
+ */
384
+ align?: CellAlign;
385
+ /**
386
+ * Show the sort arrow without committing to a direction — the column can be
387
+ * sorted, but is not the one being sorted by. Implied by `sortDirection`.
388
+ */
389
+ sortable?: boolean;
390
+ /** The direction this column is currently sorted in. Turns the arrow over. */
391
+ sortDirection?: TableSortDirection;
392
+ /** Called on a tap. Supplying it makes the header a button. */
393
+ onPress?: AnimatedPressableProps['onPress'];
394
+ /** Styles the header's text. */
395
+ labelClassName?: string;
396
+ children?: ReactNode;
397
+ }
398
+
399
+ /**
400
+ * A column header. Given `onPress` it becomes the handle for sorting by that
401
+ * column, and the arrow rotates between the two directions rather than being
402
+ * replaced, so it is clear it is the same arrow pointing the other way.
403
+ *
404
+ * The sorted column is stated twice — a full-strength arrow *and* a label that
405
+ * takes the foreground colour and a heavier weight. One signal at the size of a
406
+ * sort arrow is too quiet to notice: a press that only nudges a dim 14px chevron
407
+ * reads as a press that did nothing, even when the rows behind it did move.
408
+ *
409
+ * The component never sorts. It renders the direction it is told and reports
410
+ * the press; which column, which way and in what order stay with the caller,
411
+ * because the data being sorted is theirs.
412
+ */
413
+ const TableHead = forwardRef<View, TableHeadProps>(
414
+ (
415
+ {
416
+ className,
417
+ labelClassName,
418
+ align = 'start',
419
+ sortable,
420
+ sortDirection,
421
+ onPress,
422
+ flex,
423
+ width,
424
+ children,
425
+ style,
426
+ ...props
427
+ },
428
+ ref
429
+ ) => {
430
+ const { size } = useContext(TableContext);
431
+ const { head, headLabel } = tableVariants({ size });
432
+ const arrowColor = useCSSVariable('--color-muted-foreground');
433
+ const activeArrowColor = useCSSVariable('--color-foreground');
434
+
435
+ const showArrow = sortable || !!sortDirection;
436
+ const turn = useSharedValue(sortDirection === 'desc' ? 1 : 0);
437
+
438
+ useEffect(() => {
439
+ turn.value = withTiming(sortDirection === 'desc' ? 1 : 0, {
440
+ duration: SORT_DURATION,
441
+ });
442
+ }, [sortDirection, turn]);
443
+
444
+ const arrowStyle = useAnimatedStyle(() => ({
445
+ transform: [{ rotate: `${turn.value * 180}deg` }],
446
+ }));
447
+
448
+ const classes = head({ className: cn(alignment[align], className) });
449
+ const label = (
450
+ <>
451
+ {textChildren(children, (text) => (
452
+ <Text
453
+ className={headLabel({
454
+ className: cn(sortDirection && 'font-semibold text-foreground', labelClassName),
455
+ })}
456
+ >
457
+ {text}
458
+ </Text>
459
+ ))}
460
+ {showArrow ? (
461
+ <Animated.View
462
+ // Dimmed until this is the column being sorted by: the arrow is an
463
+ // affordance first and an answer second. Far enough down that the
464
+ // two states are not mistaken for each other at a glance.
465
+ style={[arrowStyle, sortDirection ? undefined : { opacity: 0.35 }]}
466
+ >
467
+ <ChevronUpIcon
468
+ size={sortDirection ? 16 : 14}
469
+ color={
470
+ sortDirection
471
+ ? typeof activeArrowColor === 'string'
472
+ ? activeArrowColor
473
+ : '#0a0a0a'
474
+ : typeof arrowColor === 'string'
475
+ ? arrowColor
476
+ : '#737373'
477
+ }
478
+ />
479
+ </Animated.View>
480
+ ) : null}
481
+ </>
482
+ );
483
+
484
+ if (!onPress) {
485
+ return (
486
+ <View
487
+ ref={ref}
488
+ role="columnheader"
489
+ className={classes}
490
+ style={[columnStyle({ flex, width }), style]}
491
+ {...props}
492
+ >
493
+ {label}
494
+ </View>
495
+ );
496
+ }
497
+
498
+ return (
499
+ <AnimatedPressable
500
+ ref={ref}
501
+ accessibilityRole="button"
502
+ accessibilityState={{ selected: !!sortDirection }}
503
+ accessibilityHint={
504
+ sortDirection === 'asc'
505
+ ? 'Sorts this column in descending order'
506
+ : sortDirection === 'desc'
507
+ ? 'Sorts this column in ascending order'
508
+ : 'Sorts the table by this column'
509
+ }
510
+ onPress={(event) => {
511
+ // The rows re-order under the finger; the tick is what ties that to
512
+ // the press, on the frame of the press rather than after the sort.
513
+ selectionTick();
514
+ onPress?.(event);
515
+ }}
516
+ className={classes}
517
+ style={[columnStyle({ flex, width }), style]}
518
+ {...props}
519
+ >
520
+ {label}
521
+ </AnimatedPressable>
522
+ );
523
+ }
524
+ );
525
+ TableHead.displayName = 'Table.Head';
526
+
527
+ export interface TableCellProps extends ViewProps {
528
+ className?: string;
529
+ /**
530
+ * Share of the leftover width, relative to the other cells in the row.
531
+ * Defaults to 1. Must match the `flex` on the head above it.
532
+ */
533
+ flex?: number;
534
+ /**
535
+ * Fixed width in pixels, for a column that must not move. Must match the
536
+ * `width` on the head above it.
537
+ */
538
+ width?: number;
539
+ /** Which edge the cell's content sits against. Match the head above it. */
540
+ align?: CellAlign;
541
+ /** Styles the cell's text. */
542
+ labelClassName?: string;
543
+ children?: ReactNode;
544
+ }
545
+
546
+ /**
547
+ * One cell of a row. Bare text is wrapped in the cell's own type style, so a
548
+ * row of strings needs no `Text` around each one; anything else — a Badge, an
549
+ * Avatar, a Button — is rendered as given.
550
+ */
551
+ const TableCell = forwardRef<View, TableCellProps>(
552
+ (
553
+ { className, labelClassName, align = 'start', flex, width, children, style, ...props },
554
+ ref
555
+ ) => {
556
+ const { size } = useContext(TableContext);
557
+ const { cell, cellLabel } = tableVariants({ size });
558
+
559
+ return (
560
+ <View
561
+ ref={ref}
562
+ role="cell"
563
+ className={cell({ className: cn(alignment[align], className) })}
564
+ style={[columnStyle({ flex, width }), style]}
565
+ {...props}
566
+ >
567
+ {textChildren(children, (text) => (
568
+ <Text className={cellLabel({ className: labelClassName })}>{text}</Text>
569
+ ))}
570
+ </View>
571
+ );
572
+ }
573
+ );
574
+ TableCell.displayName = 'Table.Cell';
575
+
576
+ export interface TableCaptionProps extends TextProps {
577
+ className?: string;
578
+ }
579
+
580
+ /**
581
+ * A line about the table as a whole — what it counts, when it was last read.
582
+ * Place it after the body: a caption read before the columns is a heading, and
583
+ * a heading is not this component's job.
584
+ */
585
+ const TableCaption = forwardRef<React.ElementRef<typeof Text>, TableCaptionProps>(
586
+ ({ className, ...props }, ref) => {
587
+ const { size } = useContext(TableContext);
588
+ const { caption } = tableVariants({ size });
589
+
590
+ return <Text ref={ref} className={caption({ className })} {...props} />;
591
+ }
592
+ );
593
+ TableCaption.displayName = 'Table.Caption';
594
+
595
+ export interface TableEmptyProps extends ViewProps {
596
+ className?: string;
597
+ children?: ReactNode;
598
+ }
599
+
600
+ /**
601
+ * Stands in for the body when there is nothing to show. A table with a header
602
+ * and no rows under it looks broken rather than empty, and the header is worth
603
+ * keeping: it says what would be there.
604
+ */
605
+ const TableEmpty = forwardRef<View, TableEmptyProps>(
606
+ ({ className, children, ...props }, ref) => {
607
+ const { size } = useContext(TableContext);
608
+ const { caption } = tableVariants({ size });
609
+
610
+ return (
611
+ <View
612
+ ref={ref}
613
+ role="row"
614
+ className={cn('w-full items-center justify-center px-4 py-8', className)}
615
+ {...props}
616
+ >
617
+ {textChildren(children, (text) => (
618
+ <Text className={caption()}>{text}</Text>
619
+ ))}
620
+ </View>
621
+ );
622
+ }
623
+ );
624
+ TableEmpty.displayName = 'Table.Empty';
625
+
626
+ export interface TableFrameProps extends Omit<ViewProps, 'children'> {
627
+ className?: string;
628
+ /** Caption on the tray, above the column headings. */
629
+ title?: ReactNode;
630
+ /** Trailing slot on the title row — a button, a badge, a menu. */
631
+ action?: ReactNode;
632
+ /** A line under the title, for what the table is counting. */
633
+ description?: ReactNode;
634
+ /** Row density, as on `Table`. */
635
+ size?: TableSize;
636
+ /** Tint every other body row, as on `Table`. */
637
+ striped?: boolean;
638
+ children?: ReactNode;
639
+ }
640
+
641
+ /**
642
+ * The table in a widget shell: column headings on the tray, rows in the card
643
+ * below.
644
+ *
645
+ * The headings are lifted out of the card because they are not data. Sitting on
646
+ * the tray they read as the label for the block, the way the muted caption above
647
+ * any other panel does, and the card underneath holds nothing but rows — so the
648
+ * first row is a row rather than the thing after the header.
649
+ *
650
+ * The lift is done here rather than by the caller because the alternative is
651
+ * declaring every column twice: a heading row outside the table and a body
652
+ * inside it, with `flex` and `width` kept in agreement across the gap by hand.
653
+ * Given the whole table, this can take the `Table.Header` out of it and leave
654
+ * everything measuring against the same padding.
655
+ *
656
+ * ```tsx
657
+ * <Table.Frame title="Invoices" action={<Badge>5</Badge>}>
658
+ * <Table.Header>
659
+ * <Table.Row>
660
+ * <Table.Head flex={2}>Invoice</Table.Head>
661
+ * <Table.Head align="end">Amount</Table.Head>
662
+ * </Table.Row>
663
+ * </Table.Header>
664
+ * <Table.Body>…</Table.Body>
665
+ * </Table.Frame>
666
+ * ```
667
+ */
668
+ const TableFrame = forwardRef<View, TableFrameProps>(
669
+ (
670
+ {
671
+ className,
672
+ title,
673
+ action,
674
+ description,
675
+ size = 'default',
676
+ striped = false,
677
+ children,
678
+ ...props
679
+ },
680
+ ref
681
+ ) => {
682
+ const context = useMemo(() => ({ size, striped }), [size, striped]);
683
+
684
+ const { heading, body } = useMemo(() => {
685
+ let found: ReactElement<TableHeaderProps> | null = null;
686
+ const remaining: ReactNode[] = [];
687
+
688
+ for (const child of Children.toArray(children)) {
689
+ if (!found && isValidElement(child) && child.type === TableHeader) {
690
+ // The tray already draws the rule the panel's top border is, so the
691
+ // header's own would double it.
692
+ found = cloneElement(child as ReactElement<TableHeaderProps>, {
693
+ className: cn('border-b-0', (child.props as TableHeaderProps).className),
694
+ });
695
+ continue;
696
+ }
697
+ remaining.push(child);
698
+ }
699
+
700
+ return { heading: found, body: remaining };
701
+ }, [children]);
702
+
703
+ const captioned = title !== undefined || description !== undefined || !!action;
704
+
705
+ return (
706
+ <TableContext.Provider value={context}>
707
+ <Frame ref={ref} role="table" className={className} {...props}>
708
+ {captioned ? (
709
+ <Frame.Header className={cn('items-start', heading && 'pb-1.5')}>
710
+ <View className="min-w-0 flex-1 gap-0.5">
711
+ {textChildren(title, (text) => (
712
+ <Frame.Title>{text}</Frame.Title>
713
+ ))}
714
+ {textChildren(description, (text) => (
715
+ <Frame.Description>{text}</Frame.Description>
716
+ ))}
717
+ </View>
718
+ {action ? <Frame.Action>{action}</Frame.Action> : null}
719
+ </Frame.Header>
720
+ ) : null}
721
+
722
+ {heading ? (
723
+ // No horizontal padding of its own: `Table.Row` brings the same
724
+ // `px-4` the rows in the panel do, which is the whole reason the
725
+ // headings still line up with their cells from out here.
726
+ <View className={cn('pb-2', captioned ? 'pt-0' : 'pt-2.5')}>{heading}</View>
727
+ ) : null}
728
+
729
+ <Frame.Panel dividers={false}>{body}</Frame.Panel>
730
+ </Frame>
731
+ </TableContext.Provider>
732
+ );
733
+ }
734
+ );
735
+ TableFrame.displayName = 'Table.Frame';
736
+
737
+ export const Table = Object.assign(TableRoot, {
738
+ Frame: TableFrame,
739
+ Header: TableHeader,
740
+ Body: TableBody,
741
+ Footer: TableFooter,
742
+ Row: TableRow,
743
+ Head: TableHead,
744
+ Cell: TableCell,
745
+ Caption: TableCaption,
746
+ Empty: TableEmpty,
747
+ });