@lotics/ui 41.1.0 → 41.4.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/src/timeline.tsx CHANGED
@@ -14,11 +14,55 @@ export interface TimelineItem {
14
14
  * renders a tinted disc of this color; the icon takes the full color. */
15
15
  iconColor: string;
16
16
  isLoading?: boolean;
17
+ /**
18
+ * The row's identity line — CLAMPED TO TWO LINES, because it is a scan target
19
+ * before it is a sentence. A feed is the one place user-authored prose lands in
20
+ * a row primitive, and unclamped it eats the section: measured on a real
21
+ * interaction log, one 700-character summary drew a 180px label — nine lines
22
+ * for one entry, and the disc, which centres on the label row, ended up 80px
23
+ * below the first line it was meant to sit beside.
24
+ *
25
+ * So a label that can run long belongs in `details` as well, in full. The
26
+ * clamp is the contract rather than a prop: a prop would let the next caller
27
+ * re-open the same hole, and the whole point is that the row keeps its beat
28
+ * whatever the caller hands it.
29
+ */
17
30
  label: string;
18
- /** An always-visible sub-line under the label (a note, a detail). When set, the
19
- * row top-aligns so `right` sits next to the label, not centred on the block. */
31
+ /**
32
+ * The label is a STAND-IN the surface supplied, not a value the record holds —
33
+ * render it in the placeholder tone the kit uses for an unfilled field.
34
+ *
35
+ * A feed fills up from more than one direction: a person writes an entry, but
36
+ * an automation also drops one the moment a recording lands, and an extraction
37
+ * files one from a screenshot. Those arrive with no words in them, and a row
38
+ * whose identity line is `string` forces the caller to invent some — which
39
+ * then renders in body ink, indistinguishable from a note somebody actually
40
+ * wrote. "Nobody has written this up" and "this is what happened" must not
41
+ * look alike; without this flag the type makes them.
42
+ */
43
+ placeholder?: boolean;
44
+ /** An always-visible sub-line under the label (a note, a detail) — clamped to
45
+ * two lines, same reason. When set, the row top-aligns so `right` sits next to
46
+ * the label, not centred on the block. */
20
47
  description?: string;
21
48
  error?: string;
49
+ /**
50
+ * Trailing content — a duration, a badge, a status. It renders BESIDE the press
51
+ * target rather than inside it, so an interactive one stays clickable on its
52
+ * own: nesting a control in the row's own button is invalid HTML (`<button>`
53
+ * cannot contain `<button>`) and hands one click to two handlers. The chevron
54
+ * closes the pressable region; `right` sits after it.
55
+ *
56
+ * **On a row that EXPANDS, prefer putting verbs in the body instead.** A
57
+ * control sharing the label row with the press target produced three separate
58
+ * defects here, and they are not independent: it nested a button in a button,
59
+ * it needed a fixed-height box of its own to stay on the label's first line,
60
+ * and it sat close enough to the chevron to read as one cluster with it. All
61
+ * three exist only because something interactive shares the row. In the body
62
+ * there is no press target to share with, and the verbs sit next to the
63
+ * content they act on — where an expansion the reader chose is also the look
64
+ * before a destructive act. Keep `right` for what it is good at: a fact.
65
+ */
22
66
  right?: ReactNode;
23
67
  details?: ReactNode;
24
68
  }
@@ -50,20 +94,44 @@ export function Timeline(props: TimelineProps) {
50
94
  const isFirst = index === 0;
51
95
  const isLast = index === items.length - 1;
52
96
 
53
- // The label row: label + right + chevron only. Secondary lines
54
- // (description/error) render BELOW in the spine band, so the disc can
55
- // center against this row whatever its height (a plain 20px label or a
56
- // 40px inline control in `right` both align label-on-disc).
97
+ // The label row: label + chevron only. Secondary lines
98
+ // (description/error) render BELOW in the spine band.
99
+ //
100
+ // Everything in this band aligns to the label's FIRST LINE, never to the
101
+ // label BLOCK — see FIRST_LINE_CENTRE. That is why the row tops its
102
+ // children and the chevron carries an offset instead of being centred:
103
+ // centring matches first-line alignment exactly until a label wraps, and
104
+ // then quietly stops.
57
105
  const row = (
58
106
  <View style={styles.row}>
59
- <Text size="sm" style={{ flex: 1 }}>{item.label}</Text>
60
- {item.right}
107
+ <Text
108
+ size="sm"
109
+ numberOfLines={2}
110
+ // The same ink an unfilled field draws (`Not set`, `No date set`),
111
+ // so an unwritten row reads as unwritten everywhere it appears.
112
+ color={item.placeholder ? "zinc-400" : undefined}
113
+ style={{ flex: 1 }}
114
+ >
115
+ {item.label}
116
+ </Text>
61
117
  {hasDetails ? (
62
- <Icon name={expanded ? "chevron-up" : "chevron-down"} size={14} color={colors.zinc[400]} />
118
+ <View style={styles.onFirstLine}>
119
+ <Icon name={expanded ? "chevron-up" : "chevron-down"} size={14} color={colors.zinc[400]} />
120
+ </View>
63
121
  ) : null}
64
122
  </View>
65
123
  );
66
124
 
125
+ // `right` centres inside a first-line-tall box, so a duration, a badge
126
+ // and a row-action menu all land on the label's first line whatever
127
+ // their own height, and stay there when the label wraps. Which box
128
+ // depends on whether it sits inside the row's padding or beside it.
129
+ const trailing = item.right ? (
130
+ <View style={hasDetails ? styles.onFirstLineOutdented : styles.onFirstLine}>
131
+ {item.right}
132
+ </View>
133
+ ) : null;
134
+
67
135
  return (
68
136
  <View key={item.id}>
69
137
  {/* Band 1 — disc beside the label row, vertically CENTERED on it.
@@ -71,7 +139,7 @@ export function Timeline(props: TimelineProps) {
71
139
  through tall rows. */}
72
140
  <View style={styles.labelBand}>
73
141
  <View style={styles.discColumn}>
74
- <View style={styles.halfSpineSlot}>{!isFirst ? <View style={styles.spineFill} /> : null}</View>
142
+ <View style={styles.spineAbove}>{!isFirst ? <View style={styles.spineFill} /> : null}</View>
75
143
  <AnimationFadeIn key={`${item.id}-${item.icon}`}>
76
144
  {/* Tinted disc: a low-alpha wash of the accent behind a full-color icon. */}
77
145
  <View style={[styles.node, { backgroundColor: withAlpha(item.iconColor, 0.1) }]}>
@@ -82,15 +150,23 @@ export function Timeline(props: TimelineProps) {
82
150
  )}
83
151
  </View>
84
152
  </AnimationFadeIn>
85
- <View style={styles.halfSpineSlot}>{!isLast ? <View style={styles.spineFill} /> : null}</View>
153
+ <View style={styles.spineBelow}>{!isLast ? <View style={styles.spineFill} /> : null}</View>
86
154
  </View>
87
155
  <View style={styles.rowColumn}>
88
156
  {hasDetails ? (
89
- <PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
90
- {row}
91
- </PressableHighlight>
157
+ // `right` is a SIBLING of the press target, never a child of
158
+ // it — see the prop's doc comment.
159
+ <View style={styles.rowSplit}>
160
+ <PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
161
+ {row}
162
+ </PressableHighlight>
163
+ {trailing}
164
+ </View>
92
165
  ) : (
93
- <View style={styles.plainRow}>{row}</View>
166
+ <View style={styles.plainRow}>
167
+ {row}
168
+ {trailing}
169
+ </View>
94
170
  )}
95
171
  </View>
96
172
  </View>
@@ -100,7 +176,7 @@ export function Timeline(props: TimelineProps) {
100
176
  <View style={styles.belowBand}>
101
177
  <View style={styles.spineColumn}>{!isLast ? <View style={styles.spineFill} /> : null}</View>
102
178
  <View style={[styles.belowColumn, isLast && !hasBelow ? styles.belowColumnLast : null]}>
103
- {item.description ? <Text size="xs" color="muted">{item.description}</Text> : null}
179
+ {item.description ? <Text size="xs" color="muted" numberOfLines={2}>{item.description}</Text> : null}
104
180
  {item.error ? (
105
181
  <Text size="xs" color="danger" numberOfLines={1}>{item.error}</Text>
106
182
  ) : null}
@@ -114,19 +190,55 @@ export function Timeline(props: TimelineProps) {
114
190
  );
115
191
  }
116
192
 
193
+ /** Line box of the label (`Text size="sm"`). */
194
+ const LINE_HEIGHT = 20;
195
+ /** The row's vertical padding — also the press target's breathing room. */
196
+ const ROW_PAD_Y = 10;
197
+ /** The disc itself, and the air it keeps around it. */
198
+ const NODE_SIZE = 32;
199
+ const NODE_MARGIN_Y = 3;
200
+ /** The disc's own box — DERIVED, because `spineAbove` is sized by the difference
201
+ * between this and the first line. Hand-copied as `38` it drifted the moment
202
+ * either number above changed, and the symptom would have been the disc sliding
203
+ * off the very line FIRST_LINE_CENTRE exists to hold it on. */
204
+ const NODE_BOX = NODE_SIZE + NODE_MARGIN_Y * 2;
205
+
206
+ /**
207
+ * WHERE THE LABEL'S FIRST LINE SITS, measured from the top of the label band —
208
+ * and the single number every ornament in that band derives from.
209
+ *
210
+ * It exists because "centre it on the row" and "put it on the first line" are
211
+ * the same answer right up until a label wraps, and then they are 10px apart,
212
+ * silently: the disc slides to the middle of the pair while the one-line rows
213
+ * beside it stay put, and the column stops being a column. Only the wrapped rows
214
+ * are wrong, so nothing about the screen says which number is the mistake.
215
+ *
216
+ * Every ornament reads this instead of picking its own — the disc, the chevron,
217
+ * and whatever a caller puts in `right`.
218
+ */
219
+ const FIRST_LINE_CENTRE = ROW_PAD_Y + LINE_HEIGHT / 2;
220
+
117
221
  const styles = StyleSheet.create({
118
222
  labelBand: {
119
223
  flexDirection: "row",
120
224
  gap: 12,
121
225
  },
122
- // Stretches to the label row's height; the disc centers on it, half-spines
123
- // fill the remainder so the line never breaks on a tall row.
226
+ // Stretches to the label row's height so the spine below the disc can fill it;
227
+ // the disc itself is PINNED to the first line rather than centred on the block
228
+ // (see FIRST_LINE_CENTRE).
124
229
  discColumn: {
125
230
  width: 32,
126
231
  alignSelf: "stretch",
127
232
  alignItems: "center",
128
233
  },
129
- halfSpineSlot: {
234
+ // A fixed sliver above the disc puts its centre on the label's first line; the
235
+ // slot below flexes, so the spine still reaches the bottom of a tall row.
236
+ spineAbove: {
237
+ height: FIRST_LINE_CENTRE - NODE_BOX / 2,
238
+ alignItems: "center",
239
+ justifyContent: "center",
240
+ },
241
+ spineBelow: {
130
242
  flex: 1,
131
243
  alignItems: "center",
132
244
  justifyContent: "center",
@@ -138,12 +250,12 @@ const styles = StyleSheet.create({
138
250
  backgroundColor: colors.zinc[200],
139
251
  },
140
252
  node: {
141
- width: 32,
142
- height: 32,
143
- borderRadius: 16,
253
+ width: NODE_SIZE,
254
+ height: NODE_SIZE,
255
+ borderRadius: NODE_SIZE / 2,
144
256
  justifyContent: "center",
145
257
  alignItems: "center",
146
- marginVertical: 3,
258
+ marginVertical: NODE_MARGIN_Y,
147
259
  },
148
260
  rowColumn: {
149
261
  flex: 1,
@@ -167,31 +279,79 @@ const styles = StyleSheet.create({
167
279
  },
168
280
  row: {
169
281
  flexDirection: "row",
170
- alignItems: "center",
282
+ // TOP, not centre — the whole band aligns to the first line.
283
+ alignItems: "flex-start",
171
284
  gap: 10,
172
285
  flex: 1,
173
286
  },
174
- // The press target for expandable rows; pressable rows bleed the hover wash
175
- // past the text column.
287
+ // Centres its content on the first line, for anything INSIDE the row's padding
288
+ // the chevron, and `right` on a row that is not a press target.
289
+ //
290
+ // `height`, never `minHeight`. A floor lets the box GROW to whatever the
291
+ // caller passed, and a grown box centres its content on its own new middle
292
+ // rather than on the line: against these numbers a 28px `ActionMenu` in a
293
+ // plain row's `right` lands 4px low and a 40px control 10px low, while a 16px
294
+ // duration — the only `right` in the kit's own examples — sits perfectly,
295
+ // which is exactly how a defect like this survives review. A FIXED box
296
+ // overflows symmetrically instead (a View does not clip), so the content's
297
+ // centre stays pinned whatever its size.
298
+ onFirstLine: {
299
+ height: LINE_HEIGHT,
300
+ justifyContent: "center",
301
+ },
302
+ // The same job for `right` on an EXPANDABLE row, where it sits outside the
303
+ // press target and so does not inherit that padding. Two styles rather than
304
+ // one because the difference is real: same target line, different origin.
305
+ onFirstLineOutdented: {
306
+ height: FIRST_LINE_CENTRE * 2,
307
+ justifyContent: "center",
308
+ },
309
+ // Holds the press target and `right` side by side, so the wash marks exactly
310
+ // what a press acts on. The 8px BLEED lives here rather than on the pressable:
311
+ // owned by the child it would push `right` into the gap, and a wash sliding
312
+ // under a control is how a row action stops being visible at the moment the
313
+ // pointer reaches it.
314
+ rowSplit: {
315
+ flexDirection: "row",
316
+ alignItems: "flex-start",
317
+ // WIDER than the gap INSIDE the press target (10), because these are not
318
+ // peers: the chevron belongs to the row's own button, `right` does not. At 4
319
+ // the two sat 12px apart against the chevron's 10 — near enough to 1:1 that
320
+ // the eye read them as one cluster, with only a 4px break in the hover wash
321
+ // saying otherwise, which a reader who never hovers never sees. 20 against
322
+ // 10 is the ratio that makes them two things.
323
+ gap: 12,
324
+ marginHorizontal: -8,
325
+ },
326
+ // The press target for expandable rows; the horizontal padding puts the label
327
+ // back on the column's own left edge, which the bleed above moved off it.
328
+ //
329
+ // There is no `minHeight` any more, and none is missing: `ROW_PAD_Y` on both
330
+ // sides of one line IS the 40px touch target, so the two can no longer
331
+ // disagree. A floor above the content was also what made this row centre its
332
+ // children in slack it owned — the drift FIRST_LINE_CENTRE exists to remove.
176
333
  pressableRow: {
334
+ flex: 1,
177
335
  borderRadius: 8,
178
336
  paddingHorizontal: 8,
179
- paddingVertical: 4,
180
- marginHorizontal: -8,
181
- minHeight: 40,
337
+ paddingVertical: ROW_PAD_Y,
182
338
  flexDirection: "row",
183
- alignItems: "center",
339
+ alignItems: "flex-start",
184
340
  },
185
341
  plainRow: {
186
- paddingVertical: 4,
187
- minHeight: 40,
342
+ paddingVertical: ROW_PAD_Y,
188
343
  flexDirection: "row",
189
- alignItems: "center",
344
+ alignItems: "flex-start",
345
+ gap: 10,
190
346
  },
347
+ // NO left padding. It was 2, which put a row's body 2px right of the
348
+ // description line directly above it — one column of text at two left edges,
349
+ // small enough to read as sloppiness rather than as a defect, and the reason a
350
+ // reader cannot say what is wrong with a screen that measures fine everywhere
351
+ // else. An indent is legitimate only when something VISIBLE occupies it.
191
352
  detailsContainer: {
192
353
  gap: 8,
193
354
  paddingTop: 8,
194
- paddingLeft: 2,
195
355
  },
196
356
  });
197
357