@lotics/ui 1.15.0 → 1.17.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.
@@ -0,0 +1,304 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { View, StyleSheet, Pressable } from "react-native";
3
+ import { Text } from "./text";
4
+ import { Button } from "./button";
5
+ import { colors } from "./colors";
6
+ import { isExcelMimeType } from "./mime";
7
+ import { downloadFileFromUrl } from "./download";
8
+ import { type PreviewLabels } from "./file_preview_types";
9
+ import type { DisplayFile } from "./file_thumbnail";
10
+ import type { WorkbookModel } from "@lotics/xlsx/workbook_model";
11
+ import type { RenderConfig } from "@lotics/xlsx/canvas_renderer";
12
+ import type { MergedCellRange } from "@lotics/xlsx/types";
13
+ import type { LayoutConfig } from "@lotics/xlsx/canvas_layout";
14
+
15
+ // All of @lotics/xlsx (parser, formula engine, canvas renderer) is dynamic-
16
+ // imported so a consumer that never opens a spreadsheet doesn't bundle the
17
+ // engine. Only `import type` is static here. Functions are captured on mount.
18
+ interface XlsxApi {
19
+ loadWorkbookFromSnapshot: typeof import("@lotics/xlsx/workbook_model").loadWorkbookFromSnapshot;
20
+ recomputeIfStale: typeof import("@lotics/xlsx/formula_engine").recomputeIfStale;
21
+ drawSpreadsheet: typeof import("@lotics/xlsx/canvas_renderer").drawSpreadsheet;
22
+ LayoutEngine: typeof import("@lotics/xlsx/canvas_layout").LayoutEngine;
23
+ rowColToRef: typeof import("@lotics/xlsx/excel_utils").rowColToRef;
24
+ refToRowCol: typeof import("@lotics/xlsx/excel_utils").refToRowCol;
25
+ parseExcelBuffer: typeof import("@lotics/xlsx/excel_parser").parseExcelBuffer;
26
+ parseCsvFile: typeof import("@lotics/xlsx/csv_parser").parseCsvFile;
27
+ }
28
+
29
+ async function loadXlsx(): Promise<XlsxApi> {
30
+ const [model, formula, renderer, layout, utils, excel, csv] = await Promise.all([
31
+ import("@lotics/xlsx/workbook_model"),
32
+ import("@lotics/xlsx/formula_engine"),
33
+ import("@lotics/xlsx/canvas_renderer"),
34
+ import("@lotics/xlsx/canvas_layout"),
35
+ import("@lotics/xlsx/excel_utils"),
36
+ import("@lotics/xlsx/excel_parser"),
37
+ import("@lotics/xlsx/csv_parser"),
38
+ ]);
39
+ return {
40
+ loadWorkbookFromSnapshot: model.loadWorkbookFromSnapshot,
41
+ recomputeIfStale: formula.recomputeIfStale,
42
+ drawSpreadsheet: renderer.drawSpreadsheet,
43
+ LayoutEngine: layout.LayoutEngine,
44
+ rowColToRef: utils.rowColToRef,
45
+ refToRowCol: utils.refToRowCol,
46
+ parseExcelBuffer: excel.parseExcelBuffer,
47
+ parseCsvFile: csv.parseCsvFile,
48
+ };
49
+ }
50
+
51
+ async function fetchArrayBuffer(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
52
+ const response = await fetch(url, { signal });
53
+ if (!response.ok) throw new Error(`Spreadsheet fetch failed (${response.status})`);
54
+ return response.arrayBuffer();
55
+ }
56
+
57
+ interface SpreadsheetViewProps {
58
+ file: DisplayFile;
59
+ labels: PreviewLabels;
60
+ onError?: (error: unknown, meta: { fileId: string; mimeType: string }) => void;
61
+ }
62
+
63
+ interface ParseState {
64
+ loading: boolean;
65
+ workbook: WorkbookModel | undefined;
66
+ error: string | undefined;
67
+ }
68
+
69
+ /**
70
+ * Read-only spreadsheet preview: parses Excel/CSV into a `WorkbookModel` and
71
+ * paints it with the `@lotics/xlsx` canvas renderer. Virtual-scroll — a
72
+ * transparent scroll layer (sized to the sheet) sits over a viewport-sized
73
+ * canvas that redraws on scroll. No editing, ribbon, or selection.
74
+ */
75
+ export function SpreadsheetView({ file, labels, onError }: SpreadsheetViewProps) {
76
+ const apiRef = useRef<XlsxApi | null>(null);
77
+ const [state, setState] = useState<ParseState>({ loading: true, workbook: undefined, error: undefined });
78
+ const [sheetIndex, setSheetIndex] = useState(0);
79
+
80
+ const viewportRef = useRef<HTMLDivElement>(null);
81
+ const scrollRef = useRef<HTMLDivElement>(null);
82
+ const canvasRef = useRef<HTMLCanvasElement>(null);
83
+ const rafRef = useRef<number | null>(null);
84
+ const [viewport, setViewport] = useState({ w: 0, h: 0 });
85
+ const [scroll, setScroll] = useState({ x: 0, y: 0 });
86
+
87
+ // Parse the file into a workbook (loads the engine on first spreadsheet open).
88
+ useEffect(() => {
89
+ const controller = new AbortController();
90
+ setState({ loading: true, workbook: undefined, error: undefined });
91
+ const run = async () => {
92
+ const api = apiRef.current ?? (apiRef.current = await loadXlsx());
93
+ const parsed = isExcelMimeType(file.mimeType)
94
+ ? api.parseExcelBuffer(await fetchArrayBuffer(file.url, controller.signal))
95
+ : await api.parseCsvFile(file.url, controller.signal);
96
+ if (controller.signal.aborted) return;
97
+ const wb = api.loadWorkbookFromSnapshot(parsed);
98
+ api.recomputeIfStale(wb);
99
+ if (controller.signal.aborted) return;
100
+ setSheetIndex(wb.activeSheetIndex);
101
+ setState({ loading: false, workbook: wb, error: undefined });
102
+ };
103
+ run().catch((err: unknown) => {
104
+ if (controller.signal.aborted) return;
105
+ if (err instanceof DOMException && err.name === "AbortError") return;
106
+ const passwordProtected = err instanceof Error && err.name === "PasswordProtectedError";
107
+ onError?.(err, { fileId: file.id, mimeType: file.mimeType });
108
+ setState({
109
+ loading: false,
110
+ workbook: undefined,
111
+ error: passwordProtected ? labels.passwordProtected : labels.loadFailed,
112
+ });
113
+ });
114
+ return () => controller.abort();
115
+ }, [file.url, file.id, file.mimeType, labels.loadFailed, labels.passwordProtected, onError]);
116
+
117
+ // Track the viewport size.
118
+ useEffect(() => {
119
+ const el = viewportRef.current;
120
+ if (!el || typeof ResizeObserver === "undefined") return;
121
+ const update = () => setViewport({ w: el.clientWidth, h: el.clientHeight });
122
+ update();
123
+ const ro = new ResizeObserver(update);
124
+ ro.observe(el);
125
+ return () => ro.disconnect();
126
+ }, [state.workbook]);
127
+
128
+ // Coalesce native scroll (which fires faster than frames) into one redraw per frame.
129
+ const onScroll = useCallback(() => {
130
+ if (rafRef.current != null) return;
131
+ rafRef.current = requestAnimationFrame(() => {
132
+ rafRef.current = null;
133
+ const el = scrollRef.current;
134
+ if (el) setScroll({ x: el.scrollLeft, y: el.scrollTop });
135
+ });
136
+ }, []);
137
+ useEffect(
138
+ () => () => {
139
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
140
+ },
141
+ [],
142
+ );
143
+
144
+ const layout = useMemo(() => {
145
+ const api = apiRef.current;
146
+ const wb = state.workbook;
147
+ if (!api || !wb) return null;
148
+ const s = wb.sheets[sheetIndex];
149
+ if (!s) return null;
150
+ const used = s.getUsedRange();
151
+ const config: LayoutConfig = {
152
+ colCount: Math.max(used.maxCol + 2, 26),
153
+ rowCount: Math.max(used.maxRow + 5, 50),
154
+ colWidths: s.colWidths,
155
+ rowHeights: s.rowHeights,
156
+ defaultColWidth: s.defaultColWidth,
157
+ defaultRowHeight: s.defaultRowHeight,
158
+ hiddenCols: s.hiddenCols,
159
+ hiddenRows: s.hiddenRows,
160
+ freeze: s.freeze,
161
+ };
162
+ return new api.LayoutEngine(config);
163
+ }, [state.workbook, sheetIndex]);
164
+
165
+ // Redraw the visible range whenever scroll / size / sheet changes.
166
+ useEffect(() => {
167
+ const api = apiRef.current;
168
+ const wb = state.workbook;
169
+ const canvas = canvasRef.current;
170
+ if (!api || !wb || !layout || !canvas || viewport.w === 0 || viewport.h === 0) return;
171
+ const s = wb.sheets[sheetIndex];
172
+ if (!s) return;
173
+ const dpr = window.devicePixelRatio || 1;
174
+ // Resize only when the viewport actually changes — assigning width/height
175
+ // reallocates (and clears) the backing store, so doing it per scroll frame
176
+ // is wasteful. On scroll the size is unchanged; drawSpreadsheet clears via
177
+ // its own full-viewport fillRect, so a plain redraw suffices.
178
+ const wPx = Math.round(viewport.w * dpr);
179
+ const hPx = Math.round(viewport.h * dpr);
180
+ if (canvas.width !== wPx) canvas.width = wPx;
181
+ if (canvas.height !== hPx) canvas.height = hPx;
182
+ const ctx = canvas.getContext("2d");
183
+ if (!ctx) return;
184
+
185
+ const mergedCells: MergedCellRange[] = s.mergedCells.map((ref) => {
186
+ const [a, b] = ref.split(":");
187
+ const start = api.refToRowCol(a ?? "");
188
+ const end = api.refToRowCol(b ?? a ?? "");
189
+ return {
190
+ startRow: start?.row ?? 1,
191
+ startCol: start?.col ?? 1,
192
+ endRow: end?.row ?? start?.row ?? 1,
193
+ endCol: end?.col ?? start?.col ?? 1,
194
+ };
195
+ });
196
+
197
+ const config: RenderConfig = {
198
+ layout,
199
+ scrollX: scroll.x,
200
+ scrollY: scroll.y,
201
+ viewWidth: viewport.w,
202
+ viewHeight: viewport.h,
203
+ devicePixelRatio: dpr,
204
+ getCell: (row, col) => {
205
+ const cell = s.getCell(api.rowColToRef(row, col));
206
+ if (!cell) return undefined;
207
+ return {
208
+ displayValue: cell.displayValue,
209
+ style: wb.styles.get(cell.styleIndex),
210
+ richText: cell.richText,
211
+ isHyperlink: s.hyperlinks.has(api.rowColToRef(row, col)) || undefined,
212
+ };
213
+ },
214
+ mergedCells,
215
+ freeze: s.freeze,
216
+ showGridLines: s.view.showGridLines,
217
+ images: s.images,
218
+ charts: s.charts,
219
+ drawings: s.drawings,
220
+ richText: (row, col) => s.getCell(api.rowColToRef(row, col))?.richText,
221
+ };
222
+ api.drawSpreadsheet(ctx, config);
223
+ }, [state.workbook, sheetIndex, layout, viewport, scroll]);
224
+
225
+ if (state.loading) {
226
+ return (
227
+ <View style={styles.center}>
228
+ <Text size="sm" color="muted">
229
+
230
+ </Text>
231
+ </View>
232
+ );
233
+ }
234
+ if (state.error !== undefined || !state.workbook) {
235
+ return (
236
+ <View style={styles.center}>
237
+ <Text size="sm" color="muted">
238
+ {state.error ?? labels.loadFailed}
239
+ </Text>
240
+ <View style={{ marginTop: 12 }}>
241
+ <Button
242
+ icon="download"
243
+ title={labels.download}
244
+ color="secondary"
245
+ onPress={() => void downloadFileFromUrl(file.url, file.filename)}
246
+ />
247
+ </View>
248
+ </View>
249
+ );
250
+ }
251
+
252
+ const sheets = state.workbook.sheets;
253
+ const totalWidth = layout?.totalWidth ?? 0;
254
+ const totalHeight = layout?.totalHeight ?? 0;
255
+
256
+ return (
257
+ <View style={styles.container}>
258
+ <div style={viewportStyle} ref={viewportRef}>
259
+ <canvas ref={canvasRef} style={{ ...canvasStyle, width: viewport.w, height: viewport.h }} />
260
+ <div ref={scrollRef} onScroll={onScroll} style={scrollLayerStyle}>
261
+ <div style={{ width: totalWidth, height: totalHeight }} />
262
+ </div>
263
+ </div>
264
+ {sheets.length > 1 ? (
265
+ <View style={styles.tabs}>
266
+ {sheets.map((s, i) => (
267
+ <Pressable
268
+ key={i}
269
+ accessibilityRole="tab"
270
+ accessibilityState={{ selected: i === sheetIndex }}
271
+ onPress={() => {
272
+ setSheetIndex(i);
273
+ setScroll({ x: 0, y: 0 });
274
+ if (scrollRef.current) scrollRef.current.scrollTo({ left: 0, top: 0 });
275
+ }}
276
+ style={[styles.tab, i === sheetIndex ? styles.tabActive : null]}
277
+ >
278
+ <Text size="xs" color={i === sheetIndex ? "default" : "muted"} numberOfLines={1}>
279
+ {s.name}
280
+ </Text>
281
+ </Pressable>
282
+ ))}
283
+ </View>
284
+ ) : null}
285
+ </View>
286
+ );
287
+ }
288
+
289
+ const styles = StyleSheet.create({
290
+ container: { flex: 1 },
291
+ center: { flex: 1, justifyContent: "center", alignItems: "center", padding: 48 },
292
+ tabs: {
293
+ flexDirection: "row",
294
+ borderTopWidth: 1,
295
+ borderTopColor: colors.border,
296
+ backgroundColor: colors.zinc[50],
297
+ },
298
+ tab: { paddingHorizontal: 14, paddingVertical: 8, borderRightWidth: 1, borderRightColor: colors.border },
299
+ tabActive: { backgroundColor: colors.background },
300
+ });
301
+
302
+ const viewportStyle: React.CSSProperties = { position: "relative", flex: 1, overflow: "hidden" };
303
+ const canvasStyle: React.CSSProperties = { position: "absolute", top: 0, left: 0 };
304
+ const scrollLayerStyle: React.CSSProperties = { position: "absolute", inset: 0, overflow: "auto" };
package/src/table.tsx CHANGED
@@ -1,19 +1,40 @@
1
- import { ScrollView, View, ViewStyle, StyleSheet } from "react-native";
1
+ import { Pressable, ScrollView, View, ViewStyle, StyleSheet } from "react-native";
2
2
  import { Text } from "@lotics/ui/text";
3
3
  import { ReactNode } from "react";
4
4
  import { colors } from "@lotics/ui/colors";
5
5
 
6
+ export type SortDir = "asc" | "desc";
7
+ export interface TableSort<TRow extends Record<string, unknown>> {
8
+ key: keyof TRow;
9
+ dir: SortDir;
10
+ }
11
+
6
12
  interface TableProps<TRow extends Record<string, unknown>> {
7
13
  columns: Column<TRow>[];
8
14
  rows: (TRow | null | false)[];
9
15
  rowKey?: keyof TRow;
10
16
  rowStyle?: (row: TRow) => ViewStyle | undefined;
17
+ /** Current sort. Pair with `onSortChange` to render sortable headers. The
18
+ * parent owns the actual sorting of `rows` — this only drives the indicator. */
19
+ sort?: TableSort<TRow> | null;
20
+ /** Pressing a `sortable` header calls this with the column key (the parent
21
+ * toggles asc/desc and re-sorts `rows`). */
22
+ onSortChange?: (key: keyof TRow) => void;
23
+ /** Pressing a row calls this — e.g. to open a detail drilldown. The row
24
+ * becomes a Pressable with a hover/press surface. Interactive cells nested
25
+ * inside (Pressables/buttons) capture their own press and don't bubble. */
26
+ onRowPress?: (row: TRow) => void;
11
27
  }
12
28
 
13
29
  interface Column<TRow extends Record<string, unknown>> {
14
30
  key: keyof TRow;
15
31
  label: string;
16
32
  width?: number;
33
+ /** Horizontal alignment of header + cell content. Default "left". */
34
+ align?: "left" | "right";
35
+ /** When true (and `onSortChange` is set), the header is pressable + shows a
36
+ * sort arrow when this column is the active `sort`. */
37
+ sortable?: boolean;
17
38
  renderCell?: RenderCell<TRow>;
18
39
  }
19
40
 
@@ -25,48 +46,80 @@ type RenderCell<TRow extends Record<string, unknown>> = (params: {
25
46
  const stickyHeader = [0];
26
47
 
27
48
  export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
28
- const { columns, rows, rowKey, rowStyle } = props;
49
+ const { columns, rows, rowKey, rowStyle, sort, onSortChange, onRowPress } = props;
29
50
 
30
51
  return (
31
52
  <ScrollView style={styles.container} stickyHeaderIndices={stickyHeader}>
32
53
  <View style={styles.headerRow}>
33
- {columns.map((column) => (
54
+ {columns.map((column) => {
55
+ const sortable = column.sortable && !!onSortChange;
56
+ const active = sort?.key === column.key;
57
+ const cellStyle = [
58
+ column.width ? { width: column.width } : styles.flexColumn,
59
+ styles.headerCell,
60
+ column.align === "right" ? styles.alignEnd : null,
61
+ ];
62
+ const inner = (
63
+ <View style={styles.headerInner}>
64
+ <Text weight="medium" numberOfLines={1} userSelect="none">
65
+ {column.label}
66
+ </Text>
67
+ {sortable ? (
68
+ <Text size="xs" color="muted" userSelect="none">
69
+ {active ? (sort?.dir === "asc" ? "↑" : "↓") : "↕"}
70
+ </Text>
71
+ ) : null}
72
+ </View>
73
+ );
74
+ return sortable ? (
75
+ <Pressable
76
+ key={column.key as string}
77
+ accessibilityRole="button"
78
+ accessibilityLabel={`Sắp xếp theo ${column.label}`}
79
+ onPress={() => onSortChange?.(column.key)}
80
+ style={cellStyle}
81
+ >
82
+ {inner}
83
+ </Pressable>
84
+ ) : (
85
+ <View key={column.key as string} style={cellStyle}>
86
+ {inner}
87
+ </View>
88
+ );
89
+ })}
90
+ </View>
91
+ {rows.filter(Boolean).map((r, i) => {
92
+ const row = r as TRow;
93
+ const extraStyle = rowStyle?.(row);
94
+ const cells = columns.map((column) => (
34
95
  <View
96
+ key={column.key as string}
35
97
  style={[
36
98
  column.width ? { width: column.width } : styles.flexColumn,
37
- styles.headerCell,
99
+ styles.bodyCell,
100
+ column.align === "right" ? styles.alignEnd : null,
38
101
  ]}
39
- key={column.key as string}
40
102
  >
41
- <Text weight="medium" numberOfLines={1} userSelect="none">
42
- {column.label}
43
- </Text>
103
+ {column.renderCell ? (
104
+ column.renderCell({ row, column })
105
+ ) : (
106
+ <Text numberOfLines={1}>{row[column.key]?.toString()}</Text>
107
+ )}
44
108
  </View>
45
- ))}
46
- </View>
47
- {rows.filter(Boolean).map((r, i) => {
48
- const row = r as TRow;
49
- const extraStyle = rowStyle?.(row);
50
- return (
51
- <View
52
- key={rowKey ? String(row[rowKey]) : i}
53
- style={[styles.bodyRow, extraStyle]}
109
+ ));
110
+ const key = rowKey ? String(row[rowKey]) : i;
111
+ return onRowPress ? (
112
+ <Pressable
113
+ key={key}
114
+ accessibilityRole="button"
115
+ onPress={() => onRowPress(row)}
116
+ style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extraStyle]}
54
117
  >
55
- {columns.map((column) => (
56
- <View
57
- key={column.key as string}
58
- style={[
59
- column.width ? { width: column.width } : styles.flexColumn,
60
- styles.bodyCell,
61
- ]}
62
- >
63
- {column.renderCell ? (
64
- column.renderCell({ row, column })
65
- ) : (
66
- <Text numberOfLines={1}>{row[column.key]?.toString()}</Text>
67
- )}
68
- </View>
69
- ))}
118
+ {cells}
119
+ </Pressable>
120
+ ) : (
121
+ <View key={key} style={[styles.bodyRow, extraStyle]}>
122
+ {cells}
70
123
  </View>
71
124
  );
72
125
  })}
@@ -86,19 +139,35 @@ const styles = StyleSheet.create({
86
139
  backgroundColor: colors.zinc[50],
87
140
  },
88
141
  headerCell: {
89
- height: 44,
142
+ minHeight: 40,
90
143
  justifyContent: "center",
91
- paddingHorizontal: 8,
144
+ paddingHorizontal: 12,
145
+ paddingVertical: 10,
92
146
  },
93
- bodyRow: {
147
+ headerInner: {
148
+ flexDirection: "row",
149
+ alignItems: "center",
150
+ gap: 4,
151
+ },
152
+ bodyRow: {
94
153
  flexDirection: "row",
95
154
  borderBottomWidth: 1,
96
155
  borderBottomColor: colors.border,
156
+ alignItems: "stretch",
157
+ },
158
+ rowPressed: {
159
+ backgroundColor: colors.zinc[50],
97
160
  },
161
+ // No fixed height — the row sizes to its content + vertical padding, so rich
162
+ // cells (avatar chips, buttons, stacked badges) get room instead of squeezing.
98
163
  bodyCell: {
99
164
  minHeight: 44,
100
165
  justifyContent: "center",
101
- paddingHorizontal: 8,
166
+ paddingHorizontal: 12,
167
+ paddingVertical: 12,
168
+ },
169
+ alignEnd: {
170
+ alignItems: "flex-end",
102
171
  },
103
172
  flexColumn: {
104
173
  flex: 1,