panelui-native 0.30.0 → 0.34.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.
- package/README.md +4 -2
- package/lib/module/components/button/index.js +106 -11
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/calendar/index.js +107 -24
- package/lib/module/components/calendar/index.js.map +1 -1
- package/lib/module/components/drawer/index.js +477 -0
- package/lib/module/components/drawer/index.js.map +1 -0
- package/lib/module/components/heatmap-chart/index.js +9 -8
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/map/index.js +50 -4
- package/lib/module/components/map/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +1024 -0
- package/lib/module/components/panelside/index.js.map +1 -0
- package/lib/module/components/plan/index.js +307 -21
- package/lib/module/components/plan/index.js.map +1 -1
- package/lib/module/components/post/index.js +992 -0
- package/lib/module/components/post/index.js.map +1 -0
- package/lib/module/components/response/index.js +296 -0
- package/lib/module/components/response/index.js.map +1 -0
- package/lib/module/components/response/markdown.js +463 -0
- package/lib/module/components/response/markdown.js.map +1 -0
- package/lib/module/components/select/index.js +111 -18
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/table/index.js +632 -0
- package/lib/module/components/table/index.js.map +1 -0
- package/lib/module/icons/index.js +220 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +6 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +30 -0
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/utils/date.js +129 -22
- package/lib/module/utils/date.js.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +22 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/calendar/index.d.ts +12 -3
- package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/drawer/index.d.ts +146 -0
- package/lib/typescript/src/components/drawer/index.d.ts.map +1 -0
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/map/index.d.ts +8 -1
- package/lib/typescript/src/components/map/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +354 -0
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -0
- package/lib/typescript/src/components/plan/index.d.ts +121 -10
- package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
- package/lib/typescript/src/components/post/index.d.ts +358 -0
- package/lib/typescript/src/components/post/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/index.d.ts +91 -0
- package/lib/typescript/src/components/response/index.d.ts.map +1 -0
- package/lib/typescript/src/components/response/markdown.d.ts +88 -0
- package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +27 -2
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/table/index.d.ts +274 -0
- package/lib/typescript/src/components/table/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +41 -1
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +7 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +20 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/date.d.ts +49 -5
- package/lib/typescript/src/utils/date.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/button/index.tsx +140 -8
- package/src/components/calendar/index.tsx +136 -23
- package/src/components/drawer/index.tsx +662 -0
- package/src/components/heatmap-chart/index.tsx +9 -9
- package/src/components/map/index.tsx +53 -4
- package/src/components/panelside/index.tsx +1358 -0
- package/src/components/plan/index.tsx +344 -21
- package/src/components/post/index.tsx +1116 -0
- package/src/components/response/index.tsx +390 -0
- package/src/components/response/markdown.ts +489 -0
- package/src/components/select/index.tsx +155 -15
- package/src/components/table/index.tsx +747 -0
- package/src/icons/index.tsx +176 -1
- package/src/index.ts +93 -0
- package/src/native/index.ts +51 -0
- 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
|
+
});
|