@lotics/ui 46.2.0 → 46.8.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 (49) hide show
  1. package/AGENTS.md +38 -1
  2. package/MIGRATION.md +87 -0
  3. package/docs/ai_patterns.md +11 -0
  4. package/docs/catalog.md +217 -21
  5. package/docs/composition.md +74 -6
  6. package/docs/data_entry.md +56 -2
  7. package/docs/reviewing.md +34 -0
  8. package/docs/templates.md +93 -30
  9. package/docs/testing.md +6 -0
  10. package/examples/tpl_board.tsx +257 -0
  11. package/examples/tpl_money.tsx +1027 -0
  12. package/package.json +261 -258
  13. package/src/accordion.tsx +7 -1
  14. package/src/alert.css +0 -1
  15. package/src/alert.tsx +8 -0
  16. package/src/axis_label_indices.ts +84 -0
  17. package/src/bar_chart.tsx +137 -16
  18. package/src/board.tsx +611 -0
  19. package/src/card.tsx +7 -1
  20. package/src/charge_lines.tsx +373 -0
  21. package/src/chip_group.tsx +57 -1
  22. package/src/dialog.tsx +46 -24
  23. package/src/drawer.tsx +21 -2
  24. package/src/file_gallery_modal.tsx +3 -0
  25. package/src/file_row.tsx +98 -5
  26. package/src/icon.tsx +6 -0
  27. package/src/inline_edit.tsx +54 -10
  28. package/src/inline_number_input.tsx +5 -1
  29. package/src/inline_text_input.tsx +1 -1
  30. package/src/line_chart.tsx +2 -2
  31. package/src/locale.tsx +26 -1
  32. package/src/matrix.tsx +23 -8
  33. package/src/modal.tsx +23 -3
  34. package/src/overlay_layer.ts +65 -0
  35. package/src/page_content.tsx +8 -22
  36. package/src/page_header.tsx +60 -11
  37. package/src/popover.tsx +29 -5
  38. package/src/reference_field.tsx +36 -13
  39. package/src/skip_link.tsx +2 -1
  40. package/src/stacked_bar_chart.tsx +31 -1
  41. package/src/table.tsx +6 -1
  42. package/src/tabs.tsx +1 -1
  43. package/src/text.tsx +21 -0
  44. package/src/tooltip.tsx +2 -1
  45. package/src/use_change_set.ts +66 -17
  46. package/src/use_scroll_seam.ts +79 -0
  47. package/examples/tpl_report.tsx +0 -410
  48. package/examples/tpl_statements.tsx +0 -221
  49. package/src/line_chart_labels.ts +0 -32
package/src/alert.tsx CHANGED
@@ -4,6 +4,7 @@ import "./alert.css";
4
4
  import { Text } from "./text";
5
5
  import { Button, ButtonColor } from "./button";
6
6
  import { useOverlayScope } from "./overlay_scope";
7
+ import { OVERLAY_Z_ABOVE } from "./overlay_layer";
7
8
 
8
9
  export type AlertButtonStyle = "default" | "cancel" | "destructive";
9
10
 
@@ -27,6 +28,13 @@ class Alert {
27
28
  if (!this.container) {
28
29
  this.container = document.createElement("div");
29
30
  this.container.id = "alert-container";
31
+ // The rung lives here rather than on the overlay's CSS class, because it
32
+ // is a fact about where an alert sits among the OTHER body-level layers —
33
+ // one table answers that (`overlay_layer.tsx`), and a stylesheet cannot
34
+ // read it. The box is zero-height, so making it a stacking context is
35
+ // free; the fixed overlay inside paints in it.
36
+ this.container.style.position = "relative";
37
+ this.container.style.zIndex = String(OVERLAY_Z_ABOVE);
30
38
  document.body.appendChild(this.container);
31
39
  }
32
40
  if (!this.root) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Which of a CATEGORICAL AXIS's positions get a printed label.
3
+ *
4
+ * The rule belongs to the axis, not to one chart: a line's x labels and a bar
5
+ * chart's per-bar labels have the same failure — a label is roughly
6
+ * `minLabelWidth` wide, so only so many fit across the track, and past that the
7
+ * axis either overlaps or ellipsises its own labels. Neither is a typography
8
+ * problem to solve by shortening the data; the labels are what thin out.
9
+ *
10
+ * ONE model serves both geometries. How many labels fit is a function of the
11
+ * TRACK and the label width — `floor(track / minLabelWidth)` — and that is the
12
+ * same question whether the labels are points on the track (a line's, at
13
+ * `i/(count-1)`) or slot centres (a bar's, at `(i+0.5)·track/count`). The two
14
+ * geometries differ only in the distance from position 0 to the first kept
15
+ * index, and by less than one slot; the count and the step are shared.
16
+ *
17
+ * What the caller must NOT assume is that thinning alone widens a label. On a
18
+ * SLOT geometry the survivor still sits in its own slot, so the space the
19
+ * dropped neighbours freed has to be handed to it explicitly — see
20
+ * {@link axisLabelStep} and `BarChart`'s label box.
21
+ *
22
+ * The rule that matters is WHERE the thinning is anchored: stepping forwards
23
+ * from the first position and then forcing the last one in collides whenever the
24
+ * count minus one is not a multiple of the step — the newest label lands a few
25
+ * pixels from the one before it while every other pair is a full step apart.
26
+ * Anchoring on the LAST position makes the spacing uniform by construction, and
27
+ * the last position is the one a reader looks up first. The first position is
28
+ * then kept only when it clears the same distance.
29
+ */
30
+ /**
31
+ * How many positions the axis skips between printed labels — 1 when every label
32
+ * fits, `count` when the track has room for only one. Exported because a SLOT
33
+ * geometry needs it twice: once to pick the labels, and once to give a survivor
34
+ * the width of the span its dropped neighbours vacated. Deriving it a second
35
+ * time at the call site is how the two drift.
36
+ *
37
+ * The floor is ONE label, not two. Forcing a second onto a track that fits one
38
+ * breaks this module's own rule — the two then land closer together than a label
39
+ * is wide, which is the overlap the thinning exists to prevent — and it is the
40
+ * narrow end that needs the rule most, because that is where a track can go to
41
+ * zero or below.
42
+ */
43
+ export function axisLabelStep(count: number, chartWidth: number, minLabelWidth = 50): number {
44
+ if (count <= 1) return 1;
45
+ const maxLabels = Math.max(1, Math.floor(chartWidth / minLabelWidth));
46
+ return Math.max(1, Math.ceil(count / maxLabels));
47
+ }
48
+
49
+ /**
50
+ * The positions to print, thinned to what the track fits.
51
+ *
52
+ * THE CONTRACT: the result is empty ONLY when there is nothing to label
53
+ * (`count <= 0`). An axis that HAS positions always prints at least one of them
54
+ * — the last, the one a reader looks up — however little room it has, so a card
55
+ * too narrow to fit a second label shows one rather than none.
56
+ *
57
+ * Whether the track has been MEASURED yet is the caller's fact, not this
58
+ * function's: a host with no layout pass reports 0 exactly as a 0px track does,
59
+ * and only the caller knows which it is holding. It answers that itself (show
60
+ * every label until measured) before asking here. Returning `[]` for both a
61
+ * zero width and a zero count made one value mean two things, and the two call
62
+ * sites duly read it two ways — the line chart as "not measured", the bar chart
63
+ * as "kept nothing", which is how a narrow card came to erase its whole label
64
+ * row.
65
+ */
66
+ export function axisLabelIndices(
67
+ count: number,
68
+ chartWidth: number,
69
+ minLabelWidth = 50,
70
+ ): number[] {
71
+ if (count <= 0) return [];
72
+ if (count === 1) return [0];
73
+
74
+ const last = count - 1;
75
+ const step = axisLabelStep(count, chartWidth, minLabelWidth);
76
+
77
+ const kept: number[] = [];
78
+ for (let i = last; i >= 0; i -= step) kept.unshift(i);
79
+
80
+ const firstKept = kept[0] ?? 0;
81
+ if (firstKept > 0 && (firstKept / last) * chartWidth >= minLabelWidth) kept.unshift(0);
82
+
83
+ return kept;
84
+ }
package/src/bar_chart.tsx CHANGED
@@ -1,11 +1,23 @@
1
- import { View, StyleSheet, type ViewStyle } from "react-native";
2
- import { useMemo } from "react";
1
+ import { View, StyleSheet, type LayoutChangeEvent, type ViewStyle } from "react-native";
2
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
+ import { axisLabelIndices } from "./axis_label_indices";
5
6
  import { useLoticsLocale } from "./locale";
6
7
 
7
8
  const DEFAULT_BAR_COLOR = colors.blue[500];
8
9
 
10
+ /** The horizontal orientation's label column, in px, when the caller names none. */
11
+ const DEFAULT_LABEL_WIDTH = 80;
12
+
13
+ /** The value axis's own column — the gutter the vertical bars start after. */
14
+ const AXIS_LABEL_WIDTH = 56;
15
+
16
+ /** The gap between bar slots, and therefore between label slots. Named because
17
+ * the label box's width is computed from the slot PITCH (slot + gap), and a
18
+ * second literal there would silently stop matching the row it measures. */
19
+ const LABEL_GAP = 8;
20
+
9
21
  const defaultFormatNumber = (n: number): string =>
10
22
  new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
11
23
 
@@ -13,6 +25,17 @@ export interface BarChartItem {
13
25
  label: string;
14
26
  value: number;
15
27
  color?: string;
28
+ /**
29
+ * The entity's own mark, before its name — a `BrandMark`, an `Avatar`, a
30
+ * status dot. A bar is one entity, and a chart beside a table over the same
31
+ * entities has to draw them the same way; the bar's COLOUR belongs to the
32
+ * measure, so it cannot carry identity as a legend swatch does.
33
+ *
34
+ * Horizontal: in the label column, ahead of the name. Vertical: above it,
35
+ * where the axis label sits — and thinned out with that label when the axis
36
+ * cannot fit them all.
37
+ */
38
+ leading?: ReactNode;
16
39
  }
17
40
 
18
41
  export interface BarChartProps {
@@ -20,6 +43,13 @@ export interface BarChartProps {
20
43
  orientation?: "vertical" | "horizontal";
21
44
  formatNumber?: (n: number) => string;
22
45
  emptyLabel?: string;
46
+ /**
47
+ * The horizontal orientation's fixed label column, in px (default 80). It is
48
+ * fixed because every row's bar has to start on one left edge; it is a prop
49
+ * because 80 fits a date and not a company name, and how long an entity's
50
+ * name runs is the caller's fact, not the chart's.
51
+ */
52
+ labelWidth?: number;
23
53
  }
24
54
 
25
55
  function useAxisTicks(maxValue: number) {
@@ -62,9 +92,13 @@ function useAxisTicks(maxValue: number) {
62
92
  }
63
93
 
64
94
  /**
65
- * The canonical SVG bar chart over `data: { label, value, color? }[]` — `orientation`
66
- * vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels. (No recharts.) For
67
- * one inline trend use `Sparkline`; for parts-of-a-whole, `PieChart` / `Breakdown`.
95
+ * The canonical SVG bar chart over `data: { label, value, color?, leading? }[]` —
96
+ * `orientation` vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels,
97
+ * `labelWidth` for the horizontal label column. (No recharts.) For one inline trend use
98
+ * `Sparkline`; for parts-of-a-whole, `PieChart` / `Breakdown`.
99
+ *
100
+ * The vertical axis prints as many labels as it fits and thins the rest, so the number of
101
+ * bars is never decided by how wide a label happens to be.
68
102
  */
69
103
  export function BarChart(props: BarChartProps) {
70
104
  const locale = useLoticsLocale();
@@ -73,12 +107,73 @@ export function BarChart(props: BarChartProps) {
73
107
  orientation = "vertical",
74
108
  formatNumber = defaultFormatNumber,
75
109
  emptyLabel = locale.chart.noData,
110
+ labelWidth = DEFAULT_LABEL_WIDTH,
76
111
  } = props;
77
112
  const maxValue = Math.max(...data.map((d) => d.value), 1);
78
113
  const axisTicks = useAxisTicks(maxValue);
79
114
  const axisMax = axisTicks[axisTicks.length - 1] || maxValue;
80
115
  const barAreaHeight = 200;
81
116
 
117
+ // The vertical axis prints as many labels as the row fits and thins the rest —
118
+ // the same rule `LineChart` runs, because it is the axis's rule and not one
119
+ // chart's. Without it the label box IS the bar slot: 13 bars in a 1000px card
120
+ // give each label 38px, so a `dd/MM` ellipsises and the axis stops being
121
+ // readable at exactly the density that makes a bar chart worth drawing. The
122
+ // BARS never thin — dropping data to fit typography is the wrong trade.
123
+ const [labelsWidth, setLabelsWidth] = useState(0);
124
+ const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
125
+ setLabelsWidth(event.nativeEvent.layout.width);
126
+ }, []);
127
+ // ONE derivation from the ONE measurement — which labels survive AND how wide
128
+ // the box each survivor gets. They are two answers about the same row, and a
129
+ // second pass over the same numbers is exactly how they came to disagree about
130
+ // it.
131
+ //
132
+ // THE SPACE THE THINNING FREED, HANDED TO THE LABEL THAT SURVIVED.
133
+ //
134
+ // Dropping a neighbour is only half the fix: the survivor still sits in its
135
+ // own bar slot, and a one-line `Text` is capped at that slot's width and
136
+ // ellipsises inside it — so the axis kept fewer labels and clipped them just
137
+ // the same, with a blank slot beside each one. The label box therefore spans
138
+ // the STEP: `step` slots and the gaps between them, centred on its bar, which
139
+ // is exactly the room the dropped labels vacated. The bar slots themselves
140
+ // never move, so an unlabelled bar stays a blank slot rather than a narrower
141
+ // one — the box overflows the slot instead of resizing it.
142
+ const axisLabels = useMemo(() => {
143
+ // Unmeasured (first paint, and any host with no layout pass) shows every
144
+ // label, which is where this started — thinning is what measurement BUYS,
145
+ // never a precondition for rendering an axis at all.
146
+ if (labelsWidth <= 0 || data.length === 0) return null;
147
+ // `labelsRow` holds the axis spacer AND one slot per bar, so its N slots
148
+ // carry N gaps rather than N-1: `track` is already `sum(slots) + N·gap`, and
149
+ // the slot PITCH is therefore `track / N` — the gaps net out exactly. The
150
+ // survivor's box spans `step` slots and the `step - 1` gaps between them,
151
+ // which is `step · pitch - gap`.
152
+ const track = labelsWidth - AXIS_LABEL_WIDTH;
153
+ const pitch = track / data.length;
154
+ const keptIndices = axisLabelIndices(data.length, track);
155
+ // The span comes from the KEPT SET, never from `axisLabelStep`. The two are
156
+ // allowed to disagree — `axisLabelIndices` re-adds index 0 when there is
157
+ // room for it, so a step of `count` can still yield two survivors — and
158
+ // sizing off the step then hands EACH of them a box spanning the whole
159
+ // track, so the two labels overlap. Reading one source of truth cannot
160
+ // drift, whatever the thinning rule becomes later.
161
+ const gaps = keptIndices.slice(1).map((v, i) => v - keptIndices[i]);
162
+ const span = gaps.length > 0 ? Math.min(...gaps) : data.length;
163
+ const boxWidth = span > 1 ? span * pitch - LABEL_GAP : 0;
164
+ return {
165
+ kept: new Set(keptIndices),
166
+ // Undefined when nothing was dropped (`step === 1`): the slot IS the box,
167
+ // and an explicit width there would only re-state it. Undefined too when
168
+ // the span is not a positive number of pixels — a card narrower than the
169
+ // value axis's own gutter leaves a track of zero or less, and a `width: 0`
170
+ // box would ERASE the label the thinning just chose to keep. Letting it
171
+ // overflow its slot is the same trade the row makes everywhere else: the
172
+ // axis thins labels, it never deletes them.
173
+ boxWidth: boxWidth > 0 ? boxWidth : undefined,
174
+ };
175
+ }, [data.length, labelsWidth]);
176
+
82
177
  if (data.length === 0) {
83
178
  return (
84
179
  <View style={styles.chartContainer}>
@@ -97,7 +192,8 @@ export function BarChart(props: BarChartProps) {
97
192
 
98
193
  return (
99
194
  <View key={index} style={styles.horizontalBarContainer}>
100
- <View style={styles.horizontalBarLabel}>
195
+ <View style={[styles.horizontalBarLabel, { width: labelWidth }]}>
196
+ {item.leading !== undefined ? item.leading : null}
101
197
  <Text size="sm" numberOfLines={1}>
102
198
  {item.label}
103
199
  </Text>
@@ -122,7 +218,7 @@ export function BarChart(props: BarChartProps) {
122
218
  );
123
219
  })}
124
220
  <View style={styles.horizontalAxisContainer}>
125
- <View style={styles.horizontalBarLabel} />
221
+ <View style={{ width: labelWidth }} />
126
222
  <View style={styles.horizontalAxisTrack}>
127
223
  {axisTicks.map((tick, index) => (
128
224
  <View
@@ -186,14 +282,27 @@ export function BarChart(props: BarChartProps) {
186
282
  })}
187
283
  </View>
188
284
  </View>
189
- <View style={styles.labelsRow}>
285
+ <View style={styles.labelsRow} onLayout={handleLabelsLayout}>
190
286
  <View style={styles.axisLabelSpacer} />
191
287
  {data.map((item, index) => (
288
+ // The slot stays whatever the axis prints, so the bars above it keep
289
+ // their positions — an unlabelled bar is a blank slot, never a
290
+ // narrower one.
192
291
  <View key={index} style={styles.labelContainer}>
193
- <Text numberOfLines={1} weight="medium">
194
- {item.label}
195
- </Text>
196
- <Text color="muted">{formatNumber(item.value)}</Text>
292
+ {axisLabels === null || axisLabels.kept.has(index) ? (
293
+ <View
294
+ style={[
295
+ styles.labelBox,
296
+ axisLabels?.boxWidth !== undefined && { width: axisLabels.boxWidth },
297
+ ]}
298
+ >
299
+ {item.leading !== undefined ? item.leading : null}
300
+ <Text numberOfLines={1} weight="medium">
301
+ {item.label}
302
+ </Text>
303
+ <Text color="muted">{formatNumber(item.value)}</Text>
304
+ </View>
305
+ ) : null}
197
306
  </View>
198
307
  ))}
199
308
  </View>
@@ -215,7 +324,7 @@ const styles = StyleSheet.create({
215
324
  gap: 8,
216
325
  },
217
326
  verticalAxis: {
218
- width: 56,
327
+ width: AXIS_LABEL_WIDTH,
219
328
  position: "relative",
220
329
  },
221
330
  verticalAxisTick: {
@@ -243,15 +352,23 @@ const styles = StyleSheet.create({
243
352
  },
244
353
  labelsRow: {
245
354
  flexDirection: "row",
246
- gap: 8,
355
+ gap: LABEL_GAP,
247
356
  },
248
357
  labelContainer: {
249
358
  flex: 1,
250
359
  alignItems: "center",
251
360
  gap: 2,
252
361
  },
362
+ // Wider than its slot when the axis thinned, and centred on the bar by the
363
+ // slot's own `alignItems` — a flex item wider than its cross-axis space
364
+ // overflows both edges equally, which is what puts the label over the blank
365
+ // slots beside it rather than clipping it inside its own.
366
+ labelBox: {
367
+ alignItems: "center",
368
+ gap: 2,
369
+ },
253
370
  axisLabelSpacer: {
254
- width: 56,
371
+ width: AXIS_LABEL_WIDTH,
255
372
  },
256
373
  horizontalChart: {
257
374
  gap: 8,
@@ -264,8 +381,12 @@ const styles = StyleSheet.create({
264
381
  alignItems: "center",
265
382
  gap: 8,
266
383
  },
384
+ // The width is the instance's `labelWidth`; what lives here is the row shape —
385
+ // the entity's mark, then its name, on one line.
267
386
  horizontalBarLabel: {
268
- width: 80,
387
+ flexDirection: "row",
388
+ alignItems: "center",
389
+ gap: 6,
269
390
  },
270
391
  horizontalBarTrack: {
271
392
  flex: 1,