@lotics/ui 1.19.0 → 1.20.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -96,7 +96,11 @@
96
96
  "./accordion": "./src/accordion.tsx",
97
97
  "./stepper": "./src/stepper.tsx",
98
98
  "./tabs": "./src/tabs.tsx",
99
- "./table": "./src/table.tsx",
99
+ "./table": {
100
+ "react-native": "./src/table.tsx",
101
+ "default": "./src/table.web.tsx"
102
+ },
103
+ "./table_types": "./src/table_types.ts",
100
104
  "./auto_sizer": "./src/auto_sizer.tsx",
101
105
  "./animation_horizontal_slide": "./src/animation_horizontal_slide.tsx",
102
106
  "./group_avatar": "./src/group_avatar.tsx",
package/src/table.tsx CHANGED
@@ -1,68 +1,51 @@
1
- import { Pressable, ScrollView, View, ViewStyle, StyleSheet } from "react-native";
1
+ import { Fragment, useState } from "react";
2
+ import { Pressable, ScrollView, View, StyleSheet } from "react-native";
2
3
  import { Text } from "@lotics/ui/text";
3
- import { ReactNode } from "react";
4
+ import { Icon } from "@lotics/ui/icon";
4
5
  import { colors } from "@lotics/ui/colors";
6
+ import type { Column, TableProps } from "./table_types";
5
7
 
6
- export type SortDir = "asc" | "desc";
7
- export interface TableSort<TRow extends Record<string, unknown>> {
8
- key: keyof TRow;
9
- dir: SortDir;
10
- }
11
-
12
- interface TableProps<TRow extends Record<string, unknown>> {
13
- columns: Column<TRow>[];
14
- rows: (TRow | null | false)[];
15
- rowKey?: keyof TRow;
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;
27
- }
8
+ export type { SortDir, TableSort, Column, TableProps } from "./table_types";
28
9
 
29
- interface Column<TRow extends Record<string, unknown>> {
30
- key: keyof TRow;
31
- label: string;
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;
38
- renderCell?: RenderCell<TRow>;
39
- }
40
-
41
- type RenderCell<TRow extends Record<string, unknown>> = (params: {
42
- row: TRow;
43
- column: Column<TRow>;
44
- }) => ReactNode;
10
+ // Native table. RN primitives; a click-to-expand row is a Pressable whose
11
+ // `interactive` cells are their own Pressables — RN's responder system grants the
12
+ // press to the innermost responder, so an action cell never toggles the row (the
13
+ // web DOM-nesting concern doesn't exist here). The detail panel renders below.
45
14
 
46
15
  const stickyHeader = [0];
16
+ const CHEVRON_W = 44;
47
17
 
48
18
  export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
49
- const { columns, rows, rowKey, rowStyle, sort, onSortChange, onRowPress } = props;
19
+ const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow } = props;
20
+ const [internal, setInternal] = useState<Set<string>>(() => new Set());
21
+ const expanded = expandedKeys ?? internal;
22
+ const expandable = !!renderDetail;
23
+
24
+ const toggle = (key: string, row: TRow) => {
25
+ onToggleRow?.(key, row);
26
+ if (expandedKeys === undefined) {
27
+ setInternal((prev) => {
28
+ const next = new Set(prev);
29
+ if (next.has(key)) next.delete(key);
30
+ else next.add(key);
31
+ return next;
32
+ });
33
+ }
34
+ };
35
+
36
+ const cellWidth = (col: Column<TRow>) => (col.width ? { width: col.width } : styles.flexColumn);
50
37
 
51
38
  return (
52
39
  <ScrollView style={styles.container} stickyHeaderIndices={stickyHeader}>
53
40
  <View style={styles.headerRow}>
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
- ];
41
+ {columns.map((col) => {
42
+ const sortable = col.sortable && !!onSortChange;
43
+ const active = sort?.key === col.key;
44
+ const cellStyle = [cellWidth(col), styles.headerCell, col.align === "right" ? styles.alignEnd : null];
62
45
  const inner = (
63
46
  <View style={styles.headerInner}>
64
- <Text weight="medium" numberOfLines={1} userSelect="none">
65
- {column.label}
47
+ <Text size="xs" weight="medium" color="muted" transform="uppercase" numberOfLines={1} userSelect="none">
48
+ {col.label}
66
49
  </Text>
67
50
  {sortable ? (
68
51
  <Text size="xs" color="muted" userSelect="none">
@@ -72,55 +55,45 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
72
55
  </View>
73
56
  );
74
57
  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
- >
58
+ <Pressable key={col.key as string} accessibilityRole="button" accessibilityLabel={`Sắp xếp theo ${col.label}`} onPress={() => onSortChange?.(col.key)} style={cellStyle}>
82
59
  {inner}
83
60
  </Pressable>
84
61
  ) : (
85
- <View key={column.key as string} style={cellStyle}>
62
+ <View key={col.key as string} style={cellStyle}>
86
63
  {inner}
87
64
  </View>
88
65
  );
89
66
  })}
67
+ {expandable ? <View style={{ width: CHEVRON_W }} /> : null}
90
68
  </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) => (
95
- <View
96
- key={column.key as string}
97
- style={[
98
- column.width ? { width: column.width } : styles.flexColumn,
99
- styles.bodyCell,
100
- column.align === "right" ? styles.alignEnd : null,
101
- ]}
102
- >
103
- {column.renderCell ? (
104
- column.renderCell({ row, column })
105
- ) : (
106
- <Text numberOfLines={1}>{row[column.key]?.toString()}</Text>
107
- )}
69
+ {rows.filter((r): r is TRow => Boolean(r)).map((row, i) => {
70
+ const key = rowKey ? String(row[rowKey]) : String(i);
71
+ const isOpen = expanded.has(key);
72
+ const extra = rowStyle?.(row);
73
+ const cells = columns.map((col) => (
74
+ <View key={col.key as string} style={[cellWidth(col), styles.bodyCell, col.align === "right" ? styles.alignEnd : null]}>
75
+ {col.renderCell ? col.renderCell({ row, column: col }) : <Text numberOfLines={1}>{row[col.key] != null ? String(row[col.key]) : ""}</Text>}
108
76
  </View>
109
77
  ));
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]}
117
- >
118
- {cells}
119
- </Pressable>
120
- ) : (
121
- <View key={key} style={[styles.bodyRow, extraStyle]}>
122
- {cells}
123
- </View>
78
+ return (
79
+ <Fragment key={key}>
80
+ {expandable ? (
81
+ <Pressable
82
+ accessibilityRole="button"
83
+ accessibilityState={{ expanded: isOpen }}
84
+ onPress={() => toggle(key, row)}
85
+ style={({ pressed }) => [styles.bodyRow, pressed ? styles.rowPressed : null, extra]}
86
+ >
87
+ {cells}
88
+ <View style={styles.chevron}>
89
+ <Icon name={isOpen ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
90
+ </View>
91
+ </Pressable>
92
+ ) : (
93
+ <View style={[styles.bodyRow, extra]}>{cells}</View>
94
+ )}
95
+ {expandable && isOpen ? <View style={styles.detail}>{renderDetail!(row)}</View> : null}
96
+ </Fragment>
124
97
  );
125
98
  })}
126
99
  </ScrollView>
@@ -128,48 +101,15 @@ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TR
128
101
  }
129
102
 
130
103
  const styles = StyleSheet.create({
131
- container: {
132
- flex: 1,
133
- marginHorizontal: -8,
134
- },
135
- headerRow: {
136
- flexDirection: "row",
137
- borderBottomWidth: 1,
138
- borderBottomColor: colors.border,
139
- backgroundColor: colors.zinc[50],
140
- },
141
- headerCell: {
142
- minHeight: 40,
143
- justifyContent: "center",
144
- paddingHorizontal: 12,
145
- paddingVertical: 10,
146
- },
147
- headerInner: {
148
- flexDirection: "row",
149
- alignItems: "center",
150
- gap: 4,
151
- },
152
- bodyRow: {
153
- flexDirection: "row",
154
- borderBottomWidth: 1,
155
- borderBottomColor: colors.border,
156
- alignItems: "stretch",
157
- },
158
- rowPressed: {
159
- backgroundColor: colors.zinc[50],
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.
163
- bodyCell: {
164
- minHeight: 44,
165
- justifyContent: "center",
166
- paddingHorizontal: 12,
167
- paddingVertical: 12,
168
- },
169
- alignEnd: {
170
- alignItems: "flex-end",
171
- },
172
- flexColumn: {
173
- flex: 1,
174
- },
104
+ container: { flex: 1, marginHorizontal: -8 },
105
+ headerRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, backgroundColor: colors.white },
106
+ headerCell: { minHeight: 40, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 10 },
107
+ headerInner: { flexDirection: "row", alignItems: "center", gap: 4 },
108
+ bodyRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: colors.border, alignItems: "stretch", backgroundColor: colors.white },
109
+ rowPressed: { backgroundColor: colors.zinc[50] },
110
+ bodyCell: { minHeight: 44, justifyContent: "center", paddingHorizontal: 12, paddingVertical: 12 },
111
+ chevron: { width: CHEVRON_W, alignItems: "center", justifyContent: "center" },
112
+ detail: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 18, backgroundColor: colors.zinc[50], borderBottomWidth: 1, borderBottomColor: colors.border },
113
+ alignEnd: { alignItems: "flex-end" },
114
+ flexColumn: { flex: 1 },
175
115
  });
@@ -0,0 +1,218 @@
1
+ import { CSSProperties, Fragment, useState } from "react";
2
+ import { Text } from "./text";
3
+ import { Icon } from "./icon";
4
+ import { colors } from "./colors";
5
+ import type { Column, TableProps } from "./table_types";
6
+
7
+ export type { SortDir, TableSort, Column, TableProps } from "./table_types";
8
+
9
+ // Web table. Built from raw <div>s (not RN Pressable) so a click-to-expand row
10
+ // can legally contain interactive cells: the row is a <div role="row">, never a
11
+ // <button>, and an `interactive` cell's clicks are skipped via a centralized
12
+ // `closest('[data-interactive]')` guard — no stopPropagation, no nested <button>,
13
+ // no absolute overlay. The detail panel renders full-width below the row.
14
+
15
+ const CHEVRON_W = 44;
16
+
17
+ function colWidth<TRow extends Record<string, unknown>>(col: Column<TRow>): CSSProperties {
18
+ return col.width ? { width: col.width, flexShrink: 0 } : { flex: 1, minWidth: 0 };
19
+ }
20
+
21
+ export function Table<TRow extends Record<string, unknown>>(props: TableProps<TRow>) {
22
+ const { columns, rows, rowKey, rowStyle, sort, onSortChange, renderDetail, expandedKeys, onToggleRow } = props;
23
+ const [internal, setInternal] = useState<Set<string>>(() => new Set());
24
+ const [hoverKey, setHoverKey] = useState<string | null>(null);
25
+ const expanded = expandedKeys ?? internal;
26
+ const expandable = !!renderDetail;
27
+
28
+ const toggle = (key: string, row: TRow) => {
29
+ onToggleRow?.(key, row);
30
+ if (expandedKeys === undefined) {
31
+ setInternal((prev) => {
32
+ const next = new Set(prev);
33
+ if (next.has(key)) next.delete(key);
34
+ else next.add(key);
35
+ return next;
36
+ });
37
+ }
38
+ };
39
+
40
+ const visibleRows = rows.filter((r): r is TRow => Boolean(r));
41
+
42
+ return (
43
+ <div style={containerStyle} role="table">
44
+ {/* Header */}
45
+ <div style={headerRowStyle} role="row">
46
+ {columns.map((col) => {
47
+ const sortable = col.sortable && !!onSortChange;
48
+ const active = sort?.key === col.key;
49
+ const style: CSSProperties = {
50
+ ...headerCellStyle,
51
+ ...colWidth(col),
52
+ justifyContent: col.align === "right" ? "flex-end" : "flex-start",
53
+ cursor: sortable ? "pointer" : "default",
54
+ };
55
+ const inner = (
56
+ <>
57
+ <Text size="xs" weight="medium" color="muted" numberOfLines={1} userSelect="none" transform="uppercase">
58
+ {col.label}
59
+ </Text>
60
+ {sortable ? (
61
+ <Text size="xs" color="muted" userSelect="none">
62
+ {active ? (sort?.dir === "asc" ? "↑" : "↓") : "↕"}
63
+ </Text>
64
+ ) : null}
65
+ </>
66
+ );
67
+ return sortable ? (
68
+ <div
69
+ key={col.key as string}
70
+ role="columnheader"
71
+ aria-sort={active ? (sort?.dir === "asc" ? "ascending" : "descending") : undefined}
72
+ tabIndex={0}
73
+ onClick={() => onSortChange?.(col.key)}
74
+ onKeyDown={(e) => {
75
+ if (e.key === "Enter" || e.key === " ") {
76
+ e.preventDefault();
77
+ onSortChange?.(col.key);
78
+ }
79
+ }}
80
+ style={style}
81
+ >
82
+ {inner}
83
+ </div>
84
+ ) : (
85
+ <div key={col.key as string} role="columnheader" style={style}>
86
+ {inner}
87
+ </div>
88
+ );
89
+ })}
90
+ {expandable ? <div style={{ width: CHEVRON_W, flexShrink: 0 }} /> : null}
91
+ </div>
92
+
93
+ {/* Body */}
94
+ {visibleRows.map((row, i) => {
95
+ const key = rowKey ? String(row[rowKey]) : String(i);
96
+ const isOpen = expanded.has(key);
97
+ const extra = (rowStyle?.(row) as CSSProperties | undefined) ?? undefined;
98
+ const rowStyleFinal: CSSProperties = {
99
+ ...bodyRowStyle,
100
+ cursor: expandable ? "pointer" : "default",
101
+ background: hoverKey === key && expandable ? colors.zinc[50] : colors.white,
102
+ ...extra,
103
+ };
104
+ return (
105
+ <Fragment key={key}>
106
+ <div
107
+ role="row"
108
+ onClick={
109
+ expandable
110
+ ? (e: React.MouseEvent) => {
111
+ // Whole-row click expands, EXCEPT clicks inside an interactive cell
112
+ // (pickers/buttons) — they keep their own behaviour. The disclosure
113
+ // chevron is the keyboard/AT affordance.
114
+ if ((e.target as HTMLElement).closest("[data-interactive]")) return;
115
+ toggle(key, row);
116
+ }
117
+ : undefined
118
+ }
119
+ onMouseEnter={expandable ? () => setHoverKey(key) : undefined}
120
+ onMouseLeave={expandable ? () => setHoverKey((k) => (k === key ? null : k)) : undefined}
121
+ style={rowStyleFinal}
122
+ >
123
+ {columns.map((col) => (
124
+ <div
125
+ key={col.key as string}
126
+ role="cell"
127
+ data-interactive={col.interactive || undefined}
128
+ style={{
129
+ ...bodyCellStyle,
130
+ ...colWidth(col),
131
+ alignItems: col.align === "right" ? "flex-end" : "flex-start",
132
+ }}
133
+ >
134
+ {col.renderCell ? (
135
+ col.renderCell({ row, column: col })
136
+ ) : (
137
+ <Text numberOfLines={1}>{row[col.key] != null ? String(row[col.key]) : ""}</Text>
138
+ )}
139
+ </div>
140
+ ))}
141
+ {expandable ? (
142
+ <button
143
+ type="button"
144
+ data-interactive
145
+ aria-expanded={isOpen}
146
+ aria-label={isOpen ? "Collapse row" : "Expand row"}
147
+ onClick={() => toggle(key, row)}
148
+ style={chevronButtonStyle}
149
+ >
150
+ <Icon name={isOpen ? "chevron-up" : "chevron-down"} size={18} color={colors.zinc[400]} />
151
+ </button>
152
+ ) : null}
153
+ </div>
154
+ {expandable && isOpen ? (
155
+ <div role="row" style={detailRowStyle}>
156
+ <div role="cell" style={detailCellStyle}>
157
+ {renderDetail!(row)}
158
+ </div>
159
+ </div>
160
+ ) : null}
161
+ </Fragment>
162
+ );
163
+ })}
164
+ </div>
165
+ );
166
+ }
167
+
168
+ const containerStyle: CSSProperties = { width: "100%", overflow: "auto" };
169
+ const headerRowStyle: CSSProperties = {
170
+ display: "flex",
171
+ position: "sticky",
172
+ top: 0,
173
+ zIndex: 1,
174
+ background: colors.white,
175
+ borderBottom: `1px solid ${colors.border}`,
176
+ };
177
+ const headerCellStyle: CSSProperties = {
178
+ minHeight: 40,
179
+ display: "flex",
180
+ flexDirection: "row",
181
+ alignItems: "center",
182
+ gap: 4,
183
+ padding: "10px 12px",
184
+ boxSizing: "border-box",
185
+ };
186
+ const bodyRowStyle: CSSProperties = {
187
+ display: "flex",
188
+ alignItems: "stretch",
189
+ borderBottom: `1px solid ${colors.border}`,
190
+ };
191
+ const bodyCellStyle: CSSProperties = {
192
+ minHeight: 44,
193
+ display: "flex",
194
+ flexDirection: "column",
195
+ justifyContent: "center",
196
+ padding: 12,
197
+ boxSizing: "border-box",
198
+ };
199
+ const chevronButtonStyle: CSSProperties = {
200
+ width: CHEVRON_W,
201
+ flexShrink: 0,
202
+ display: "flex",
203
+ alignItems: "center",
204
+ justifyContent: "center",
205
+ background: "transparent",
206
+ border: "none",
207
+ padding: 0,
208
+ cursor: "pointer",
209
+ };
210
+ const detailRowStyle: CSSProperties = {
211
+ background: colors.zinc[50],
212
+ borderBottom: `1px solid ${colors.border}`,
213
+ };
214
+ const detailCellStyle: CSSProperties = {
215
+ padding: "4px 16px 18px",
216
+ width: "100%",
217
+ boxSizing: "border-box",
218
+ };
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ViewStyle } from "react-native";
3
+
4
+ export type SortDir = "asc" | "desc";
5
+
6
+ export interface TableSort<TRow extends Record<string, unknown>> {
7
+ key: keyof TRow;
8
+ dir: SortDir;
9
+ }
10
+
11
+ export interface Column<TRow extends Record<string, unknown>> {
12
+ key: keyof TRow;
13
+ label: string;
14
+ width?: number;
15
+ /** Horizontal alignment of header + cell content. Default "left". */
16
+ align?: "left" | "right";
17
+ /** When true (and `onSortChange` is set), the header is pressable + shows a
18
+ * sort arrow when this column is the active `sort`. */
19
+ sortable?: boolean;
20
+ /** Cells whose own controls (buttons, pickers) must NOT toggle the row. The
21
+ * row's expand handler ignores clicks originating inside an interactive cell,
22
+ * so the rest of the row stays click-to-expand. */
23
+ interactive?: boolean;
24
+ renderCell?: (params: { row: TRow; column: Column<TRow> }) => ReactNode;
25
+ }
26
+
27
+ export interface TableProps<TRow extends Record<string, unknown>> {
28
+ columns: Column<TRow>[];
29
+ rows: (TRow | null | false)[];
30
+ /** Stable per-row key. Required when `renderDetail` is set (expansion is
31
+ * tracked by this key, not row index, so it survives sort/filter). */
32
+ rowKey?: keyof TRow;
33
+ rowStyle?: (row: TRow) => ViewStyle | undefined;
34
+ /** Current sort. Pair with `onSortChange` to render sortable headers; the
35
+ * parent owns the actual sorting of `rows` — this only drives the indicator. */
36
+ sort?: TableSort<TRow> | null;
37
+ onSortChange?: (key: keyof TRow) => void;
38
+ /** Render an inline detail panel, full-width below the row. When set, the
39
+ * whole row (except `interactive` cells) is click-to-expand. */
40
+ renderDetail?: (row: TRow) => ReactNode;
41
+ /** Controlled set of expanded row keys. Omit for internal (uncontrolled) state. */
42
+ expandedKeys?: Set<string>;
43
+ /** Called when a row's expand state toggles (with its `rowKey` value). */
44
+ onToggleRow?: (key: string, row: TRow) => void;
45
+ }