@lotics/ui 47.13.1 → 47.14.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,63 @@
1
+ /**
2
+ * The gridline VALUES a magnitude axis prints — the "nice" round numbers under
3
+ * the largest bar, chosen so the axis lands on about five of them.
4
+ *
5
+ * The rule belongs to the axis rather than to one chart, and it has one clause
6
+ * that is easy to miss: **an axis over whole things has no half.** A count of
7
+ * vehicles, contracts, trips or people cannot be 0,5, so a step finer than 1
8
+ * prints gridlines at quantities the data can never take — and it happens
9
+ * exactly where the numbers are smallest, because a max of 2 is where a
10
+ * five-tick target starts reaching below the unit. The reader is then asked to
11
+ * read "one and a half contracts", and the axis has stated something false
12
+ * about the subject rather than about the layout. So the candidate steps are
13
+ * filtered by what the DATA is made of: all-integer values take integer steps.
14
+ */
15
+
16
+ /** The multipliers that make a step read as round rather than arbitrary. */
17
+ const NICE_MULTIPLIERS = [1, 2, 2.5, 5, 10];
18
+
19
+ const TARGET_TICKS = 5;
20
+
21
+ export function axisTicks(values: number[]): number[] {
22
+ const maxValue = Math.max(...values, 1);
23
+ const integral = values.every((v) => Number.isInteger(v));
24
+ const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
25
+
26
+ const usable = (step: number) => step > 0 && (!integral || Number.isInteger(step));
27
+
28
+ let bestStep = magnitude;
29
+ let bestTickCount = Infinity;
30
+
31
+ for (const multiplier of NICE_MULTIPLIERS) {
32
+ const step = multiplier * magnitude;
33
+ if (!usable(step)) continue;
34
+ const tickCount = Math.ceil(maxValue / step) + 1;
35
+ if (Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)) {
36
+ bestStep = step;
37
+ bestTickCount = tickCount;
38
+ }
39
+ }
40
+
41
+ // A finer pass, so a max of 168 is not left on gridlines a hundred apart. It
42
+ // is bounded at both ends — three to eight ticks — because this is the pass
43
+ // that can run away, and for whole things it is also where the halves came
44
+ // from.
45
+ for (const multiplier of NICE_MULTIPLIERS) {
46
+ const step = multiplier * (magnitude / 10);
47
+ if (!usable(step)) continue;
48
+ const tickCount = Math.ceil(maxValue / step) + 1;
49
+ if (
50
+ tickCount >= 3 &&
51
+ tickCount <= 8 &&
52
+ Math.abs(tickCount - TARGET_TICKS) < Math.abs(bestTickCount - TARGET_TICKS)
53
+ ) {
54
+ bestStep = step;
55
+ bestTickCount = tickCount;
56
+ }
57
+ }
58
+
59
+ const stepsNeeded = Math.ceil(maxValue / bestStep);
60
+ const ticks: number[] = [];
61
+ for (let i = 0; i <= stepsNeeded; i++) ticks.push(i * bestStep);
62
+ return ticks;
63
+ }
package/src/bar_chart.tsx CHANGED
@@ -3,6 +3,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
3
3
  import { Text } from "./text";
4
4
  import { colors } from "./colors";
5
5
  import { axisLabelIndices } from "./axis_label_indices";
6
+ import { axisTicks } from "./axis_ticks";
6
7
  import { useLoticsLocale } from "./locale";
7
8
 
8
9
  const DEFAULT_BAR_COLOR = colors.blue[500];
@@ -18,6 +19,20 @@ const AXIS_LABEL_WIDTH = 56;
18
19
  * second literal there would silently stop matching the row it measures. */
19
20
  const LABEL_GAP = 8;
20
21
 
22
+ /** The horizontal orientation's fixed value column. Named because the label
23
+ * column's cap is computed from it, and a second literal there would stop
24
+ * matching the row it is measuring against. */
25
+ const VALUE_COLUMN_WIDTH = 96;
26
+
27
+ /** What the PLOT keeps whatever the label column asked for. A bar chart states
28
+ * proportion; under this the bars sit on their own 2px floor and the picture
29
+ * claims every row is equal. */
30
+ const MIN_TRACK_WIDTH = 72;
31
+
32
+ /** And what the label column keeps in return — a mark and a few characters,
33
+ * so the rows can still be told apart. */
34
+ const MIN_LABEL_WIDTH = 56;
35
+
21
36
  const defaultFormatNumber = (n: number): string =>
22
37
  new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(n);
23
38
 
@@ -60,45 +75,6 @@ export interface BarChartProps {
60
75
  height?: number;
61
76
  }
62
77
 
63
- function useAxisTicks(maxValue: number) {
64
- return useMemo(() => {
65
- const targetTickCount = 5;
66
- const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
67
- const niceMultipliers = [1, 2, 2.5, 5, 10];
68
- let bestStep = magnitude;
69
- let bestTickCount = Infinity;
70
-
71
- for (const multiplier of niceMultipliers) {
72
- const step = multiplier * magnitude;
73
- const tickCount = Math.ceil(maxValue / step) + 1;
74
- if (Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)) {
75
- bestStep = step;
76
- bestTickCount = tickCount;
77
- }
78
- }
79
-
80
- for (const multiplier of niceMultipliers) {
81
- const step = multiplier * (magnitude / 10);
82
- const tickCount = Math.ceil(maxValue / step) + 1;
83
- if (
84
- tickCount >= 3 &&
85
- tickCount <= 8 &&
86
- Math.abs(tickCount - targetTickCount) < Math.abs(bestTickCount - targetTickCount)
87
- ) {
88
- bestStep = step;
89
- bestTickCount = tickCount;
90
- }
91
- }
92
-
93
- const stepsNeeded = Math.ceil(maxValue / bestStep);
94
- const ticks: number[] = [];
95
- for (let i = 0; i <= stepsNeeded; i++) {
96
- ticks.push(i * bestStep);
97
- }
98
- return ticks;
99
- }, [maxValue]);
100
- }
101
-
102
78
  /**
103
79
  * The canonical SVG bar chart over `data: { label, value, color?, leading? }[]` —
104
80
  * `orientation` vertical|horizontal, nice auto axis ticks, `formatNumber` for value labels,
@@ -119,8 +95,10 @@ export function BarChart(props: BarChartProps) {
119
95
  height: barAreaHeight = 200,
120
96
  } = props;
121
97
  const maxValue = Math.max(...data.map((d) => d.value), 1);
122
- const axisTicks = useAxisTicks(maxValue);
123
- const axisMax = axisTicks[axisTicks.length - 1] || maxValue;
98
+ // The gridlines come from the VALUES, not from their maximum, because an axis
99
+ // over whole things must not print a half — see `axis_ticks`.
100
+ const ticks = useMemo(() => axisTicks(data.map((d) => d.value)), [data]);
101
+ const axisMax = ticks[ticks.length - 1] || maxValue;
124
102
 
125
103
  // The vertical axis prints as many labels as the row fits and thins the rest —
126
104
  // the same rule `LineChart` runs, because it is the axis's rule and not one
@@ -132,6 +110,45 @@ export function BarChart(props: BarChartProps) {
132
110
  const handleLabelsLayout = useCallback((event: LayoutChangeEvent) => {
133
111
  setLabelsWidth(event.nativeEvent.layout.width);
134
112
  }, []);
113
+
114
+ // THE HORIZONTAL ORIENTATION IS MEASURED TOO, AND FOR TWO REASONS AT ONCE.
115
+ //
116
+ // Its label column and its value column are FIXED, so a narrow container
117
+ // spends its whole width on chrome and leaves the plot a few pixels: every
118
+ // bar then renders at its own minimum, and a row of identical stubs is a
119
+ // picture stating that every entity is equal. Measured at 375, a chart with a
120
+ // 184px label column had a 5px track. So `labelWidth` is a REQUEST, never a
121
+ // reservation — it gives way before the plot does, because a trimmed name
122
+ // beside a readable bar still says more than a full name beside a stub.
123
+ //
124
+ // The value axis under it thins by the categorical rule: its ticks are
125
+ // positioned by FRACTION, so a narrow track does not crowd them, it stacks
126
+ // them — five money labels on a short track paint one unreadable smudge, and
127
+ // nothing overflows, errors or clips while it happens. The tick MARKS stay: a
128
+ // 1px line collides with nothing, and it is the label that could not fit.
129
+ const [rowWidth, setRowWidth] = useState(0);
130
+ const handleRowLayout = useCallback((event: LayoutChangeEvent) => {
131
+ setRowWidth(event.nativeEvent.layout.width);
132
+ }, []);
133
+ const horizontal = useMemo(() => {
134
+ const chrome = VALUE_COLUMN_WIDTH + LABEL_GAP * 2;
135
+ const label =
136
+ rowWidth <= 0
137
+ ? labelWidth
138
+ : Math.min(labelWidth, Math.max(MIN_LABEL_WIDTH, rowWidth - chrome - MIN_TRACK_WIDTH));
139
+ const track = rowWidth <= 0 ? 0 : rowWidth - label - chrome;
140
+ return {
141
+ labelWidth: label,
142
+ // Evenly-spaced ticks from 0 to the axis maximum sit at `i / (count - 1)`
143
+ // — the POINT geometry `axisLabelIndices` already serves for a line's x
144
+ // axis, anchored on the last tick because that is the one setting scale.
145
+ // Keyed on the MEASUREMENT, not on the track: unmeasured shows every
146
+ // label (thinning is what measurement buys), while a measured track of
147
+ // zero is a real answer and `axisLabelIndices` returns the one label that
148
+ // states the scale.
149
+ keptTicks: rowWidth <= 0 ? null : new Set(axisLabelIndices(ticks.length, Math.max(track, 0))),
150
+ };
151
+ }, [rowWidth, labelWidth, ticks.length]);
135
152
  // ONE derivation from the ONE measurement — which labels survive AND how wide
136
153
  // the box each survivor gets. They are two answers about the same row, and a
137
154
  // second pass over the same numbers is exactly how they came to disagree about
@@ -193,14 +210,14 @@ export function BarChart(props: BarChartProps) {
193
210
  if (orientation === "horizontal") {
194
211
  return (
195
212
  <View style={styles.chartContainer}>
196
- <View style={styles.horizontalChart}>
213
+ <View style={styles.horizontalChart} onLayout={handleRowLayout}>
197
214
  {data.map((item, index) => {
198
215
  const widthPercent = (item.value / axisMax) * 100;
199
216
  const barColor = item.color ?? DEFAULT_BAR_COLOR;
200
217
 
201
218
  return (
202
219
  <View key={index} style={styles.horizontalBarContainer}>
203
- <View style={[styles.horizontalBarLabel, { width: labelWidth }]}>
220
+ <View style={[styles.horizontalBarLabel, { width: horizontal.labelWidth }]}>
204
221
  {item.leading !== undefined ? item.leading : null}
205
222
  <Text size="sm" numberOfLines={1}>
206
223
  {item.label}
@@ -226,17 +243,19 @@ export function BarChart(props: BarChartProps) {
226
243
  );
227
244
  })}
228
245
  <View style={styles.horizontalAxisContainer}>
229
- <View style={{ width: labelWidth }} />
246
+ <View style={{ width: horizontal.labelWidth }} />
230
247
  <View style={styles.horizontalAxisTrack}>
231
- {axisTicks.map((tick, index) => (
248
+ {ticks.map((tick, index) => (
232
249
  <View
233
250
  key={index}
234
251
  style={[styles.horizontalAxisTick, { left: `${(tick / axisMax) * 100}%` }]}
235
252
  >
236
253
  <View style={styles.tickMark} />
237
- <Text size="xs" color="muted">
238
- {formatNumber(tick)}
239
- </Text>
254
+ {horizontal.keptTicks === null || horizontal.keptTicks.has(index) ? (
255
+ <Text size="xs" color="muted">
256
+ {formatNumber(tick)}
257
+ </Text>
258
+ ) : null}
240
259
  </View>
241
260
  ))}
242
261
  </View>
@@ -252,7 +271,7 @@ export function BarChart(props: BarChartProps) {
252
271
  <View style={styles.verticalChartWrapper}>
253
272
  <View style={styles.verticalChartRow}>
254
273
  <View style={[styles.verticalAxis, { height: barAreaHeight }]}>
255
- {axisTicks
274
+ {ticks
256
275
  .slice()
257
276
  .reverse()
258
277
  .map((tick, index) => (
@@ -411,7 +430,7 @@ const styles = StyleSheet.create({
411
430
  flex: 1,
412
431
  },
413
432
  horizontalBarValue: {
414
- width: 96,
433
+ width: VALUE_COLUMN_WIDTH,
415
434
  alignItems: "flex-end",
416
435
  },
417
436
  horizontalBar: {
@@ -11,6 +11,12 @@ export interface CellStackProps {
11
11
  *
12
12
  * One, not several: a stack of three is no longer a value and its annotation,
13
13
  * it is a row that wants to be a row.
14
+ *
15
+ * To RESERVE the line on a row that has nothing to say — which is how a column
16
+ * whose cells are sometimes two lines keeps one first-line height down the page
17
+ * (probe 8h-bis) — pass a whitespace string. `" "` is what everybody writes and
18
+ * the web renderer collapses it, so it is normalised to a non-breaking space
19
+ * here rather than in twelve call sites.
14
20
  */
15
21
  caption?: ReactNode;
16
22
  /**
@@ -24,6 +30,13 @@ export interface CellStackProps {
24
30
  * does, so the caption keeps body size beside one.
25
31
  */
26
32
  marked?: boolean;
33
+ /**
34
+ * The identity mark, drawn HERE — an `Avatar`, a `FileBadge`. Sets `marked`
35
+ * and owns the gap between the mark and the pair, so a register's subject
36
+ * column is not one more hand-rolled row of mark + stack with its own idea
37
+ * of the gap. The column still budgets the mark's width (`TableColumn.lead`).
38
+ */
39
+ leading?: ReactNode;
27
40
  /** The subject's rung. The pair takes ONE rung, so the caption follows it. */
28
41
  size?: TextSize;
29
42
  /** The subject's ink. The caption is always `muted` — it is supporting text. */
@@ -80,7 +93,8 @@ export function CellStack(props: CellStackProps) {
80
93
  title,
81
94
  caption,
82
95
  weight = "regular",
83
- marked = false,
96
+ marked: markedProp = false,
97
+ leading,
84
98
  size = "sm",
85
99
  color,
86
100
  tabular,
@@ -90,7 +104,16 @@ export function CellStack(props: CellStackProps) {
90
104
  testID,
91
105
  } = props;
92
106
 
93
- const hasCaption = caption !== undefined && caption !== null && caption !== false;
107
+ // A caption reserving the line with nothing in it. `" "` says "keep this line"
108
+ // unambiguously and collapses in the web renderer, so the reserved line loses
109
+ // its height and the cell drops to ONE line — while the row height, set by
110
+ // whichever sibling is two lines tall, never moves. The column then puts its
111
+ // first line at two different heights on rows that look identical to every
112
+ // spacing probe. One app wrote it twelve times.
113
+ const line = typeof caption === "string" && caption.length > 0 && caption.trim() === "" ? NBSP : caption;
114
+ const hasCaption = line !== undefined && line !== null && line !== false;
115
+ const hasLeading = leading !== undefined && leading !== null && leading !== false;
116
+ const marked = markedProp || hasLeading;
94
117
  // Rule 1 — the caption's rung, from what else is already separating the pair.
95
118
  const captionSize: TextSize = weight !== "regular" || marked ? size : "xs";
96
119
  // Rule 2 — tight closes a PAIR; `xs` is tight by construction, and a lone line
@@ -98,8 +121,8 @@ export function CellStack(props: CellStackProps) {
98
121
  const subjectTight = hasCaption ? "tight" : undefined;
99
122
  const captionTight = captionSize === "xs" ? undefined : "tight";
100
123
 
101
- return (
102
- <View style={[{ gap: 0, alignItems: ALIGN[align] }, style]} testID={testID}>
124
+ const stack = (
125
+ <View style={[{ gap: 0, alignItems: ALIGN[align] }, hasLeading ? { flex: 1, minWidth: 0 } : style]} testID={hasLeading ? undefined : testID}>
103
126
  {typeof title === "string" || typeof title === "number" ? (
104
127
  <Text
105
128
  size={size}
@@ -115,7 +138,7 @@ export function CellStack(props: CellStackProps) {
115
138
  title
116
139
  )}
117
140
  {hasCaption
118
- ? typeof caption === "string" || typeof caption === "number"
141
+ ? typeof line === "string" || typeof line === "number"
119
142
  ? (
120
143
  <Text
121
144
  size={captionSize}
@@ -124,15 +147,28 @@ export function CellStack(props: CellStackProps) {
124
147
  tabular={tabular}
125
148
  numberOfLines={numberOfLines}
126
149
  >
127
- {caption}
150
+ {line}
128
151
  </Text>
129
152
  )
130
- : caption
153
+ : line
131
154
  : null}
132
155
  </View>
133
156
  );
157
+ if (!hasLeading) return stack;
158
+ return (
159
+ <View style={[{ flexDirection: "row", alignItems: "center", gap: LEADING_GAP, minWidth: 0 }, style]} testID={testID}>
160
+ {leading}
161
+ {stack}
162
+ </View>
163
+ );
134
164
  }
135
165
 
166
+ const NBSP = "\u00a0";
167
+
168
+ /** Mark to pair \u2014 the ONE gap between an identity mark and the label beside it,
169
+ * wherever the kit draws that pair (`CellStack`, `Matrix`, `StateMatrix`). */
170
+ export const LEADING_GAP = 10;
171
+
136
172
  const ALIGN = {
137
173
  left: "flex-start",
138
174
  right: "flex-end",
@@ -147,12 +147,18 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
147
147
  color={option.iconColor ?? (active ? colors.zinc[900] : colors.zinc[500])}
148
148
  />
149
149
  ) : null}
150
- <Text
151
- userSelect="none"
152
- size="sm"
153
- weight={active ? "semibold" : "medium"}
154
- color={active ? "default" : "muted"}
155
- >
150
+ {/* MEDIUM on both, never semibold on the active one.
151
+
152
+ A chip already answers "which one is on" twice — a doubled
153
+ zinc-900 edge and a ring — so the weight was a third signal, and
154
+ the wrong kind: semibold is a separate, genuinely heavier FILE
155
+ and the heading ramp is semibold at every rung, so an active chip
156
+ rendered a filter value at heading weight. State steps up in INK,
157
+ which is what `color` does here. It also made the active chip a
158
+ singleton treatment on every screen carrying one, while the
159
+ sibling that answers the same question — `Tabs` — steps
160
+ regular → medium. One question, one answer. */}
161
+ <Text userSelect="none" size="sm" weight="medium" color={active ? "default" : "muted"}>
156
162
  {option.label}
157
163
  </Text>
158
164
  {option.count != null ? (
@@ -23,24 +23,32 @@ export interface DangerZoneProps {
23
23
  * a danger heading mark it as a hazard without shouting (the kit's `tint`/`solid`
24
24
  * discipline, never a raw hex); the consequence goes in `description`, and the
25
25
  * action(s) go in `children`.
26
+ *
27
+ * ONE LEFT EDGE, and it is `Callout`'s anatomy that gets it: the icon sits in an
28
+ * outer ROW beside a column holding title, consequence and actions, so all three
29
+ * derive from the same edge. Put the icon INSIDE the heading row instead — the
30
+ * obvious way to write it — and only the TITLE moves right, so the loudest line
31
+ * in the block sits 22px past the sentence explaining it and the button under
32
+ * both starts somewhere else again. Three edges inside one 16px-padded box, on
33
+ * the one primitive that exists so a hazard is not hand-rolled.
26
34
  */
27
35
  export function DangerZone(props: DangerZoneProps) {
28
36
  const locale = useLoticsLocale();
29
37
  const { title = locale.dangerZone.title, description, children } = props;
30
38
  return (
31
39
  <View style={styles.zone}>
32
- <View style={styles.heading}>
33
- <Icon name="triangle-alert" size={16} color={solid("red")} />
40
+ <Icon name="triangle-alert" size={16} color={solid("red")} />
41
+ <View style={styles.body}>
34
42
  <Text size="sm" weight="semibold" color="danger">
35
43
  {title}
36
44
  </Text>
45
+ {description ? (
46
+ <Text size="sm" color="muted">
47
+ {description}
48
+ </Text>
49
+ ) : null}
50
+ <View style={styles.actions}>{children}</View>
37
51
  </View>
38
- {description ? (
39
- <Text size="sm" color="muted">
40
- {description}
41
- </Text>
42
- ) : null}
43
- <View style={styles.actions}>{children}</View>
44
52
  </View>
45
53
  );
46
54
  }
@@ -49,13 +57,18 @@ const styles = StyleSheet.create({
49
57
  // A soft danger frame: a low-alpha red hairline over the faintest red wash —
50
58
  // the "set apart, not shouting" treatment via the kit's tint discipline.
51
59
  zone: {
52
- gap: 8,
60
+ flexDirection: "row",
61
+ alignItems: "flex-start",
62
+ gap: 10,
53
63
  padding: 16,
54
64
  borderRadius: 10,
55
65
  borderWidth: 1,
56
66
  borderColor: tint("red", 0.3),
57
67
  backgroundColor: tint("red", 0.03),
58
68
  },
59
- heading: { flexDirection: "row", alignItems: "center", gap: 6 },
69
+ // The column every line in the block starts from — 1px down so the first line
70
+ // of type sits optically level with the 16px mark beside it, exactly as
71
+ // `Callout` does it.
72
+ body: { flex: 1, gap: 8, paddingTop: 1 },
60
73
  actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
61
74
  });
@@ -33,6 +33,10 @@ export interface DateFilterLabels extends SegmentLabels {
33
33
  thisWeek: string;
34
34
  thisMonth: string;
35
35
  lastMonth: string;
36
+ thisQuarter: string;
37
+ lastQuarter: string;
38
+ thisYear: string;
39
+ lastYear: string;
36
40
  from: string;
37
41
  to: string;
38
42
  /** Accessible name of the field that OPENS this panel (`DateRangeFilterField`). */
@@ -71,6 +75,14 @@ function presetLabel(id: PresetId, labels: DateFilterLabels): string {
71
75
  return labels.thisMonth;
72
76
  case "last_month":
73
77
  return labels.lastMonth;
78
+ case "this_quarter":
79
+ return labels.thisQuarter;
80
+ case "last_quarter":
81
+ return labels.lastQuarter;
82
+ case "this_year":
83
+ return labels.thisYear;
84
+ case "last_year":
85
+ return labels.lastYear;
74
86
  }
75
87
  }
76
88
 
@@ -224,12 +236,10 @@ export function DateFilter(props: DateFilterProps) {
224
236
  (id: PresetId) => {
225
237
  // A preset replaces the whole range, so any half-picked start is abandoned.
226
238
  setPendingStart(null);
227
- // Re-clicking the active preset clears the filter
228
- if (activePresetId === id) {
229
- onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
230
- return;
231
- }
232
-
239
+ // Resolved against today on EVERY press, the active one included: a preset
240
+ // names a period, so re-pressing it re-anchors a range that has gone stale
241
+ // (a panel left open across midnight) rather than doing nothing. Clearing
242
+ // is the footer's Clear button — this list holds no null case.
233
243
  const presetValue = getPresetValue(id, new Date());
234
244
  onValueChange(presetValue);
235
245
 
@@ -240,7 +250,7 @@ export function DateFilter(props: DateFilterProps) {
240
250
  );
241
251
  }
242
252
  },
243
- [onValueChange, activePresetId],
253
+ [onValueChange],
244
254
  );
245
255
 
246
256
  // `selected` + `role="option"` is the kit's listbox row, and `MenuButton` owns