@lotics/ui 21.0.0 → 21.1.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/docs/catalog.md CHANGED
@@ -137,8 +137,13 @@ Two columnar shapes, and the choice is about data size:
137
137
  sortable header + collapsible grouped sections + aligned rows + an optional per-row
138
138
  `leading` slot (a `CheckCircle`); YOU own the data, the sort/group/filter/collapse STATE
139
139
  (`cycleSort` + `sortBy` from `@lotics/ui/sort_header`, a `collapsed` Set), the toolbar
140
- (`SearchInput` + `FilterChip`s), and the per-group add row (`renderGroupFooter`, aligned
141
- with the exported `gridRowStyle` + the column widths). It renders ALL rows (no
140
+ (`SearchInput` + `FilterChip`s). A column's `align: "right"` moves BOTH its cells and its
141
+ heading, so digits line up under their label; **`DataGridColumn.footer` gives that column its
142
+ cell in the group's footer row** (a subtotal, a resolved measure) — the grid lays it out in
143
+ the column's own box, so a total can never drift from what it sums and no consumer rebuilds
144
+ the row. `renderGroupFooter` remains for content that does NOT sit in a column (a per-group
145
+ add row). The grid takes exactly its container's width — no outdent to fight, so it needs no
146
+ scroller unless YOU add one. It renders ALL rows (no
142
147
  virtualization), so it's only for sets small enough to hold in view — at 10k+ it lags, and
143
148
  grouping + pagination/infinite don't compose; use the register instead. Worked example:
144
149
  [`tpl_task_board`](../examples/tpl_task_board.tsx) (a `CheckCircle` leading).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "21.0.0",
3
+ "version": "21.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/data_grid.tsx CHANGED
@@ -11,9 +11,17 @@ export interface DataGridColumn<T> {
11
11
  label: string;
12
12
  /** Fixed width in px; omit for the flexible primary column (usually the first). */
13
13
  width?: number;
14
+ /** Cell + header alignment. Money and counts read RIGHT so digits line up under
15
+ * their heading; text reads left (the default). */
16
+ align?: "left" | "right";
14
17
  sortable?: boolean;
15
18
  /** Render the cell for this column — any field: a `Text`, an inline editor, a badge. */
16
19
  cell: (item: T) => ReactNode;
20
+ /** This column's cell in the group's FOOTER row — a subtotal, a resolved measure.
21
+ * Declared per COLUMN so the grid lays it out in that column's own box: a total
22
+ * can never drift from the values it sums, and no consumer re-implements the row.
23
+ * The footer row renders when ANY column defines one. */
24
+ footer?: (group: DataGridGroup<T>) => ReactNode;
17
25
  }
18
26
 
19
27
  export interface DataGridGroup<T> {
@@ -39,7 +47,9 @@ export interface DataGridProps<T> {
39
47
  /** Controlled collapse — the set of collapsed group keys. */
40
48
  collapsed?: Set<string>;
41
49
  onToggleCollapse?: (key: string) => void;
42
- /** Rendered under each group's rows typically a per-group add row. Align it with `gridRowStyle` + the column widths. */
50
+ /** Rendered under each group's rows AND under the columns' own footer row
51
+ * for content that does NOT sit in a column, typically a per-group add row.
52
+ * For a column-aligned subtotal use `DataGridColumn.footer`. */
43
53
  renderGroupFooter?: (group: DataGridGroup<T>) => ReactNode;
44
54
  }
45
55
 
@@ -57,13 +67,19 @@ export interface DataGridProps<T> {
57
67
  export function DataGrid<T>(props: DataGridProps<T>) {
58
68
  const { columns, groups, getRowKey, leading, sort, onSort, labels, collapsed, onToggleCollapse, renderGroupFooter } = props;
59
69
  const lead = leading?.width ?? 0;
70
+ const hasFooter = columns.some((c) => c.footer != null);
60
71
 
61
72
  return (
62
73
  <View>
63
74
  <View style={[styles.row, styles.head]}>
64
75
  {lead > 0 ? <View style={{ width: lead }} /> : null}
76
+ {/* The header label rides the SAME column box as the cells below it, so a
77
+ right-aligned column's heading sits over its digits instead of drifting
78
+ to the column's left edge. */}
65
79
  {columns.map((c) => (
66
- <SortLabel key={c.key} label={c.label} sortKey={c.key} sortable={c.sortable !== false && !!onSort} sort={sort ?? null} onSort={onSort} labels={labels} style={colStyle(c)} />
80
+ <View key={c.key} style={colStyle(c)}>
81
+ <SortLabel label={c.label} sortKey={c.key} sortable={c.sortable !== false && !!onSort} sort={sort ?? null} onSort={onSort} labels={labels} />
82
+ </View>
67
83
  ))}
68
84
  </View>
69
85
 
@@ -95,6 +111,14 @@ export function DataGrid<T>(props: DataGridProps<T>) {
95
111
  ))}
96
112
  </View>
97
113
  ))}
114
+ {hasFooter ? (
115
+ <View style={styles.row}>
116
+ {lead > 0 ? <View style={{ width: lead }} /> : null}
117
+ {columns.map((c) => (
118
+ <View key={c.key} style={colStyle(c)}>{c.footer?.(g)}</View>
119
+ ))}
120
+ </View>
121
+ ) : null}
98
122
  {renderGroupFooter?.(g)}
99
123
  </>
100
124
  ) : null}
@@ -105,13 +129,20 @@ export function DataGrid<T>(props: DataGridProps<T>) {
105
129
  );
106
130
  }
107
131
 
108
- function colStyle(c: { width?: number }): StyleProp<ViewStyle> {
109
- return c.width != null ? { width: c.width } : styles.flexCol;
132
+ /** The shared column box — width (or flex) plus alignment. ONE definition, used by
133
+ * the header, every cell and every footer cell, so the three cannot drift. */
134
+ function colStyleImpl(c: { width?: number; align?: "left" | "right" }): StyleProp<ViewStyle> {
135
+ return [
136
+ c.width != null ? { width: c.width } : styles.flexCol,
137
+ c.align === "right" ? { alignItems: "flex-end" as const } : null,
138
+ ];
110
139
  }
140
+ const colStyle = colStyleImpl;
111
141
 
112
- // The aligned sortable header label same 8px inset as the cells, so it lines up
113
- // with the column content (the kit's `SortHeader` keeps its label flush, which
114
- // wouldn't match inline-editor cells).
142
+ // The column heading. It sits in the SAME column box as the cells, and its own
143
+ // padding is cancelled by an equal negative margin so the hover pill has room
144
+ // while the label still starts exactly on its cell's edge. Non-sortable headings
145
+ // render as plain text: no press, no pointer, no button in the a11y tree.
115
146
  function SortLabel(props: { label: string; sortKey: string; sortable: boolean; sort: SortState | null; onSort?: (k: string) => void; labels?: SortHeaderLabels; style?: StyleProp<ViewStyle> }) {
116
147
  const { label, sortKey, sortable, sort, onSort, labels, style } = props;
117
148
  const active = sort?.key === sortKey;
@@ -126,7 +157,7 @@ function SortLabel(props: { label: string; sortKey: string; sortable: boolean; s
126
157
  const sortByLabel = (labels?.sortBy ?? ((l: string) => `Sort by ${l}`))(label);
127
158
  const dirText = active ? (sort?.dir === "asc" ? (labels?.ascending ?? ", ascending") : (labels?.descending ?? ", descending")) : "";
128
159
  return (
129
- <FocusRingPressable onPress={() => onSort?.(sortKey)} accessibilityRole="button" accessibilityLabel={`${sortByLabel}${dirText}`} style={({ hovered }: { hovered?: boolean }) => [styles.sortLabel, hovered ? styles.sortLabelHover : null, style]}>
160
+ <FocusRingPressable onPress={() => onSort?.(sortKey)} accessibilityRole="button" accessibilityLabel={`${sortByLabel}${dirText}`} style={({ hovered }: { hovered?: boolean }) => [styles.sortLabel, styles.sortLabelPressable, hovered ? styles.sortLabelHover : null, style]}>
130
161
  <Text size="xs" weight="semibold" color={active ? "default" : "muted"} numberOfLines={1} style={styles.headLabel}>{label}</Text>
131
162
  {arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
132
163
  </FocusRingPressable>
@@ -139,7 +170,6 @@ export const gridRowStyle: ViewStyle = {
139
170
  alignItems: "center",
140
171
  gap: 12,
141
172
  minHeight: 44,
142
- paddingHorizontal: 8,
143
173
  };
144
174
 
145
175
  const styles = StyleSheet.create({
@@ -152,9 +182,12 @@ const styles = StyleSheet.create({
152
182
  marginBottom: 4,
153
183
  },
154
184
  headLabel: { letterSpacing: 0.3, textTransform: "uppercase" },
155
- sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 6, cursor: "pointer" },
185
+ // Padded for the hover pill but zero-width in flow (negative margin), so the label
186
+ // sits on its cell's edge instead of 8px inside it.
187
+ sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, marginHorizontal: -6, paddingVertical: 2, borderRadius: 6 },
188
+ sortLabelPressable: { cursor: "pointer" },
156
189
  sortLabelHover: { backgroundColor: colors.zinc[50] },
157
190
  groupSep: { borderTopWidth: 1, borderTopColor: colors.zinc[100], marginTop: 8, paddingTop: 8 },
158
- section: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, paddingVertical: 7, borderRadius: 8 },
191
+ section: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, marginHorizontal: -8, paddingVertical: 7, borderRadius: 8 },
159
192
  sectionHover: { backgroundColor: colors.zinc[50] },
160
193
  });
package/src/ledger.tsx CHANGED
@@ -77,7 +77,7 @@ export function LedgerGroup(props: LedgerGroupProps) {
77
77
  const { format } = useLedger();
78
78
  return (
79
79
  <View style={styles.group}>
80
- <View style={styles.row}>
80
+ <View style={styles.groupHead}>
81
81
  <Text size="xs" weight="medium" color="muted" style={styles.grow}>
82
82
  {label}
83
83
  </Text>
@@ -198,8 +198,11 @@ const styles = StyleSheet.create({
198
198
  // Outdent by the rows' 8px inset (the ListItem/Timeline technique) so row text +
199
199
  // the money column align with the section heading and the container edges;
200
200
  // the pressable door's wash bleeds into the gutter instead of squeezing text.
201
- ledger: { gap: 12, marginHorizontal: -8 },
202
- group: { gap: 4 },
201
+ // Grouping reads by PROXIMITY: the gap between groups must be decisively larger
202
+ // than the gap inside one, or the eyebrow floats midway and belongs to neither
203
+ // the group above nor the rows below. 18 between, 2 within.
204
+ ledger: { gap: 16, marginHorizontal: -8 },
205
+ group: { gap: 0 },
203
206
  // EVERY line (group header, rows, the total) shares the 8px text inset, so
204
207
  // labels and the money column sit on one edge whether a row peeks or not —
205
208
  // the pressable door's wash simply fills the same padded box.
@@ -210,6 +213,16 @@ const styles = StyleSheet.create({
210
213
  minHeight: 28,
211
214
  paddingHorizontal: 8,
212
215
  },
216
+ // The group eyebrow is a CAPTION on the rows beneath it, not a line of its own —
217
+ // it takes only the height its xs text needs, so it hugs what it labels instead
218
+ // of sitting in a full 28px row box.
219
+ groupHead: {
220
+ flexDirection: "row",
221
+ alignItems: "center",
222
+ gap: 10,
223
+ minHeight: 16,
224
+ paddingHorizontal: 8,
225
+ },
213
226
  rowPress: {
214
227
  borderRadius: 8,
215
228
  },