@textui/widgets 0.1.0 → 0.3.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 (59) hide show
  1. package/README.md +17 -15
  2. package/dist/control/checkbox.d.ts +2 -0
  3. package/dist/control/checkbox.d.ts.map +1 -1
  4. package/dist/control/checkbox.js +2 -2
  5. package/dist/control/radio-group.d.ts +2 -0
  6. package/dist/control/radio-group.d.ts.map +1 -1
  7. package/dist/control/radio-group.js +2 -2
  8. package/dist/control/text-area.d.ts +15 -2
  9. package/dist/control/text-area.d.ts.map +1 -1
  10. package/dist/control/text-area.js +402 -22
  11. package/dist/control/text-input.d.ts.map +1 -1
  12. package/dist/control/text-input.js +40 -0
  13. package/dist/data/feed.d.ts +14 -0
  14. package/dist/data/feed.d.ts.map +1 -1
  15. package/dist/data/feed.js +88 -4
  16. package/dist/data/list.d.ts +60 -7
  17. package/dist/data/list.d.ts.map +1 -1
  18. package/dist/data/list.js +66 -10
  19. package/dist/data/markdown-view.d.ts.map +1 -1
  20. package/dist/data/markdown-view.js +49 -0
  21. package/dist/data/shared.d.ts +18 -1
  22. package/dist/data/shared.d.ts.map +1 -1
  23. package/dist/data/shared.js +12 -2
  24. package/dist/display/color-text.d.ts +158 -0
  25. package/dist/display/color-text.d.ts.map +1 -0
  26. package/dist/display/color-text.js +246 -0
  27. package/dist/display/index.d.ts +1 -0
  28. package/dist/display/index.d.ts.map +1 -1
  29. package/dist/display/index.js +3 -0
  30. package/dist/layout/divider.d.ts +6 -1
  31. package/dist/layout/divider.d.ts.map +1 -1
  32. package/dist/layout/divider.js +5 -5
  33. package/dist/layout/scroll-view.d.ts +10 -0
  34. package/dist/layout/scroll-view.d.ts.map +1 -1
  35. package/dist/layout/scroll-view.js +2 -2
  36. package/dist/navigation/menu.d.ts +24 -0
  37. package/dist/navigation/menu.d.ts.map +1 -1
  38. package/dist/navigation/menu.js +58 -15
  39. package/dist/overlay/command-palette.d.ts +32 -1
  40. package/dist/overlay/command-palette.d.ts.map +1 -1
  41. package/dist/overlay/command-palette.js +84 -10
  42. package/dist/shells/workbench-shell.d.ts.map +1 -1
  43. package/dist/shells/workbench-shell.js +22 -2
  44. package/package.json +8 -8
  45. package/src/control/checkbox.ts +4 -2
  46. package/src/control/radio-group.ts +4 -2
  47. package/src/control/text-area.ts +394 -31
  48. package/src/control/text-input.ts +38 -1
  49. package/src/data/feed.ts +102 -3
  50. package/src/data/list.ts +125 -18
  51. package/src/data/markdown-view.ts +56 -0
  52. package/src/data/shared.ts +25 -3
  53. package/src/display/color-text.ts +386 -0
  54. package/src/display/index.ts +3 -0
  55. package/src/layout/divider.ts +15 -7
  56. package/src/layout/scroll-view.ts +12 -2
  57. package/src/navigation/menu.ts +89 -14
  58. package/src/overlay/command-palette.ts +125 -11
  59. package/src/shells/workbench-shell.ts +22 -3
package/src/data/feed.ts CHANGED
@@ -33,6 +33,20 @@ export interface FeedProps extends BoxProps {
33
33
  onSelect?(index: number): void;
34
34
  onActivate?(index: number): void;
35
35
  scrollbar?: boolean;
36
+ /**
37
+ * Who `pageup` and `pagedown` belong to.
38
+ *
39
+ * `focused` is the ordinary answer: the keys go to whatever has the
40
+ * keyboard. `always` claims them even while something else does - for the
41
+ * feed that *is* the screen, with a text field under it. Somebody typing a
42
+ * message who presses page up means the conversation above them; there is
43
+ * nothing else on that screen those keys could be for, and taking the
44
+ * keyboard away from the field to use them is the thing they are avoiding.
45
+ *
46
+ * Only those two keys, and only when the focused node has declined them
47
+ * first - so a field that pages its own content keeps them.
48
+ */
49
+ pageKeys?: 'focused' | 'always';
36
50
  focusable?: boolean;
37
51
  autoFocus?: boolean;
38
52
  /** So a command can send the reader here by name. */
@@ -59,6 +73,7 @@ export interface FeedProps extends BoxProps {
59
73
  export const Feed = defineComponent<FeedProps>('Feed', (props) => {
60
74
  const {
61
75
  children, follow: followProp, onFollowChange, selectedIndex, onSelect, onActivate,
76
+ pageKeys = 'focused',
62
77
  scrollbar = true, focusable = true, autoFocus, focusId, id, ...rest
63
78
  } = props;
64
79
 
@@ -70,6 +85,20 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
70
85
  const measured = useMeasure();
71
86
  const extent = useScrollExtent();
72
87
  const heights = useRef<number[]>([]);
88
+ /**
89
+ * The width those heights were measured at.
90
+ *
91
+ * A height is a statement about a width - what a paragraph wrapped to in a
92
+ * pane this wide - so a resize does not make them stale, it makes them
93
+ * about a layout that no longer exists. Kept and compared rather than
94
+ * ignored, because the alternative is a feed that scrolls to the wrong
95
+ * place for ever after the window is dragged.
96
+ */
97
+ const heightsAt = useRef(-1);
98
+ /** One handler for every entry, so an entry's props do not change with it. */
99
+ const report = useRef((index: number, height: number): void => {
100
+ heights.current[index] = height;
101
+ });
73
102
 
74
103
  // The catalog's rule, and this has to follow it like every other viewport:
75
104
  // given `flex`, a `height`, a `maxHeight` or a `basis`, the layout decided
@@ -86,7 +115,17 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
86
115
  const [internalTop, setInternalTop] = useState<number | null>(null);
87
116
  const [internalIndex, setInternalIndex] = useState(0);
88
117
 
89
- const entries = (Array.isArray(children) ? children : [children]).filter((c) => c != null);
118
+ /*
119
+ * Flattened, because a feed counts entries and JSX groups them.
120
+ *
121
+ * `{caption}{items.map(...)}` arrives as `[caption, [a, b, c]]` - two
122
+ * children, one of which is an array - so a feed of twenty entries with
123
+ * anything written beside the map counted two. The cursor then clamped to
124
+ * the second, every key after the first did nothing, and the scroll never
125
+ * moved. It reads as a dead list and is an arity bug.
126
+ */
127
+ const entries = (Array.isArray(children) ? children.flat(Infinity) : [children])
128
+ .filter((c) => c != null);
90
129
  const count = entries.length;
91
130
  const selects = onSelect !== undefined || selectedIndex !== undefined;
92
131
  const index = Math.max(0, Math.min(count - 1, selectedIndex ?? internalIndex));
@@ -166,10 +205,70 @@ export const Feed = defineComponent<FeedProps>('Feed', (props) => {
166
205
  { focusId: focus.id, enabled: focusable },
167
206
  );
168
207
 
208
+ /*
209
+ * The page keys, from wherever the keyboard happens to be.
210
+ *
211
+ * `global` handlers run only after the focused node has declined the key,
212
+ * so a field that pages its own content still keeps them - this is the one
213
+ * that catches what nothing else wanted.
214
+ */
215
+ useInput(
216
+ (event) => {
217
+ if (event.name !== 'pageup' && event.name !== 'pagedown') return false;
218
+ if (chorded(event)) return false;
219
+ const page = Math.max(1, measured.height - 2);
220
+ scrollTo(event.name === 'pageup' ? top - page : top + page);
221
+ return true;
222
+ },
223
+ { global: true, enabled: pageKeys === 'always' },
224
+ );
225
+
226
+ if (measured.width !== heightsAt.current) {
227
+ heightsAt.current = measured.width;
228
+ heights.current = [];
229
+ }
230
+
231
+ /*
232
+ * Which entries are far enough away not to be worth drawing.
233
+ *
234
+ * A feed lays out every entry it holds, and laying one out means wrapping
235
+ * its text - so a viewport thirty rows tall over a transcript of six
236
+ * hundred entries spent every frame measuring the five hundred and seventy
237
+ * nobody can see. That cost is paid per *frame*, not per change: a
238
+ * keystroke in a field elsewhere on the screen, a caret blinking, a
239
+ * spinner. It is why a long conversation is heavy to type into and a short
240
+ * one is not.
241
+ *
242
+ * An entry is skipped only once it has been measured, which is what keeps
243
+ * this honest: the first frame draws everything and learns every height,
244
+ * and from then on the ones outside the window are replaced by a box of
245
+ * exactly the height they had. Nothing is estimated, so the extent, the
246
+ * scrollbar and the position of every entry are what they would have been
247
+ * had all of them been drawn.
248
+ *
249
+ * A screenful either side, so scrolling and paging land on something that
250
+ * is already there rather than on a frame of blanks.
251
+ */
252
+ const view = Math.max(1, measured.height);
253
+ const skipped: boolean[] = [];
254
+ {
255
+ let y = 0;
256
+ for (let i = 0; i < count; i++) {
257
+ const height = heights.current[i];
258
+ // Zero is not a height that has been learned, whatever wrote it: an
259
+ // entry standing in for itself at nothing tall is one that can never
260
+ // be drawn again to find out how tall it is.
261
+ if (height === undefined || height <= 0) { skipped[i] = false; continue; }
262
+ skipped[i] = fills && (y + height < top - view || y > top + view + view);
263
+ y += height;
264
+ }
265
+ }
169
266
  const drawn = entries.map((entry, i) => h(FeedEntry, {
170
267
  key: i,
171
- onHeight: (height: number) => { heights.current[i] = height; },
172
- }, entry));
268
+ index: i,
269
+ onHeight: report.current,
270
+ ...(skipped[i] ? { placeholder: true, height: heights.current[i] as number } : {}),
271
+ }, skipped[i] ? null : entry));
173
272
 
174
273
  // Content-sized: draw everything and let the box grow. Clamping to a
175
274
  // measurement here would clamp to how tall this happened to be last frame,
package/src/data/list.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BoxProps, SemanticVariant } from '@textui/core';
1
+ import type { BoxProps, RenderOutput, SemanticVariant } from '@textui/core';
2
2
  import {
3
3
  chorded,
4
4
  defineComponent,
@@ -24,15 +24,39 @@ export interface ListItem {
24
24
  disabled?: boolean;
25
25
  }
26
26
 
27
- export interface ListProps extends BoxProps {
28
- items: ListItem[];
27
+ /** How a row stands at the moment it is asked to draw itself. */
28
+ export interface ListItemState {
29
+ /** The row the selection is on. */
30
+ selected: boolean;
31
+ /**
32
+ * ...and the list itself has the keyboard.
33
+ *
34
+ * Both, and not one: a selected row in a list that has lost focus is a
35
+ * *remembered* choice, and drawing it as loudly as a live one makes two
36
+ * panes look like they both have the cursor.
37
+ */
38
+ focused: boolean;
39
+ disabled: boolean;
40
+ }
41
+
42
+ export interface ListProps<T extends ListItem = ListItem> extends BoxProps {
43
+ /**
44
+ * The rows.
45
+ *
46
+ * `T` is whatever the caller's own row type is, so long as it is a
47
+ * `ListItem` - which is what the built-in row needs and what `id` being the
48
+ * selection's name needs. Passing plain `ListItem`s is the ordinary case and
49
+ * `T` costs nothing there; a caller with a `renderItem` gets its own fields
50
+ * back on the way in rather than a lookup by id.
51
+ */
52
+ items: T[];
29
53
  selectedId?: string;
30
- onSelect?(id: string, item: ListItem): void;
31
- onActivate?(id: string, item: ListItem): void;
54
+ onSelect?(id: string, item: T): void;
55
+ onActivate?(id: string, item: T): void;
32
56
  /** Rows visible at once. Scrolls when there are more. */
33
57
  visibleRows?: number;
34
58
  emptyMessage?: string;
35
- /** Draw a marker column for the selected row. */
59
+ /** Draw a marker column for the selected row. Drawn either way. */
36
60
  marker?: boolean;
37
61
  focusable?: boolean;
38
62
  autoFocus?: boolean;
@@ -42,14 +66,42 @@ export interface ListProps extends BoxProps {
42
66
  * nothing outside the render can know.
43
67
  */
44
68
  focusId?: string;
69
+ /**
70
+ * Draw one row's contents.
71
+ *
72
+ * The built-in row - icon, title, description, meta, on one line - is the
73
+ * shape most catalogues are, and it is what you get by leaving this alone.
74
+ * The moment a caller wants a different one, the repair is *not* another
75
+ * field on `ListItem` and another flag saying where to put it: that road
76
+ * ends with a component whose props are a small layout language, and it
77
+ * still cannot draw the row after next.
78
+ *
79
+ * So the row is the caller's, and everything a row cannot do for itself
80
+ * stays here: the selection, the keys that move it, the window that scrolls,
81
+ * the highlight, the marker column and the click. `state` is what the row
82
+ * cannot know - whether it is the selected one, and whether that selection
83
+ * is live.
84
+ *
85
+ * A row taller than one line has to say so with `itemHeight`.
86
+ */
87
+ renderItem?(item: T, state: ListItemState): RenderOutput;
88
+ /**
89
+ * Lines one row occupies, when `renderItem` draws more than one.
90
+ *
91
+ * The list scrolls by arithmetic rather than by measurement - it decides how
92
+ * many rows fit *before* anything is drawn, which is the only way a thousand
93
+ * rows cost the same as ten. That arithmetic is in lines, so a row that is
94
+ * two of them has to be declared, not discovered.
95
+ */
96
+ itemHeight?: number;
45
97
  }
46
98
 
47
- export const List = defineComponent<ListProps>('List', (props) => {
99
+ function ListView<T extends ListItem>(props: ListProps<T>): RenderOutput {
48
100
  const theme = useTheme();
49
101
  const {
50
102
  items, selectedId, onSelect, onActivate, visibleRows,
51
103
  emptyMessage = 'Nothing here', marker = true, focusable = true,
52
- autoFocus, focusId, ...rest
104
+ autoFocus, focusId, renderItem, itemHeight = 1, ...rest
53
105
  } = props;
54
106
 
55
107
  const focus = useFocus({
@@ -62,7 +114,20 @@ export const List = defineComponent<ListProps>('List', (props) => {
62
114
  items.find((i) => !i.disabled)?.id ?? null,
63
115
  );
64
116
  const currentId = selectedId ?? internalId;
65
- const pageSize = viewportRows(props, measured, 10, { requested: visibleRows });
117
+
118
+ /**
119
+ * How many *rows* fit, when what was measured is lines.
120
+ *
121
+ * `visibleRows` is already a count of rows, so it stands as given. Anything
122
+ * else comes from the space the list was handed, and a two-line row buys
123
+ * half as many of them.
124
+ */
125
+ const lines = Math.max(1, itemHeight);
126
+ const fit = (content: number): number => {
127
+ if (visibleRows !== undefined) return Math.max(1, visibleRows);
128
+ return Math.max(1, Math.floor(viewportRows(props, measured, content * lines) / lines));
129
+ };
130
+ const pageSize = fit(10);
66
131
  const index = Math.max(0, items.findIndex((i) => i.id === currentId));
67
132
 
68
133
  /**
@@ -112,13 +177,22 @@ export const List = defineComponent<ListProps>('List', (props) => {
112
177
  // Unstated, the row count comes from the space the list was given rather
113
178
  // than from how many items it holds - a thousand-row list must not decide
114
179
  // how tall its own pane is.
115
- const rows = viewportRows(props, measured, items.length, { requested: visibleRows });
180
+ const rows = fit(items.length);
116
181
  const start = Math.max(0, Math.min(index - Math.floor(rows / 2), items.length - rows));
117
182
  const window = items.slice(start, start + rows);
118
183
 
119
184
  return h('box', { id: focus.id, role: 'list', direction: 'column', ...rest },
120
185
  ...window.map((item) => {
121
186
  const active = item.id === currentId;
187
+ const state: ListItemState = {
188
+ selected: active,
189
+ focused: focus.focused,
190
+ disabled: item.disabled === true,
191
+ };
192
+ // `item.id` is the key as well as the selection's name, and deliberately
193
+ // the same string: a row that reconciled under one identity while the
194
+ // selection pointed at another would be a highlight on the wrong row,
195
+ // and no amount of looking at either one would show why.
122
196
  return h('box', {
123
197
  key: item.id,
124
198
  role: 'listitem',
@@ -126,6 +200,9 @@ export const List = defineComponent<ListProps>('List', (props) => {
126
200
  selected: active,
127
201
  direction: 'row',
128
202
  gap: 1,
203
+ // One background over the whole row, however many lines it draws: a
204
+ // highlight that stopped after the first would split the row it is
205
+ // highlighting in two.
129
206
  bg: active && focus.focused ? 'selected' : active ? 'active' : undefined,
130
207
  fg: item.disabled ? 'disabled' : active && focus.focused ? 'inverted' : undefined,
131
208
  onClick: () => {
@@ -134,16 +211,46 @@ export const List = defineComponent<ListProps>('List', (props) => {
134
211
  onSelect?.(item.id, item);
135
212
  },
136
213
  },
214
+ // The marker stays here even when the contents are the caller's. It
215
+ // belongs to the selection rather than to the row, and a column that
216
+ // every row has to remember to draw is a column that goes crooked.
137
217
  marker
138
- ? h('text', { content: active ? theme.glyphs.chevronRight : ' ' })
218
+ ? h('text', { content: active ? theme.glyphs.chevronRight : ' ', shrink: 0 })
139
219
  : null,
140
- item.icon ? h('text', { content: item.icon, fg: item.tone ? TONE[item.tone] : undefined }) : null,
141
- h('text', { content: item.label, flex: 1, truncate: 'end' }),
142
- // The secondary columns keep the row's colour once it is selected;
143
- // `muted` on a selected background is unreadable.
144
- item.description ? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end' }) : null,
145
- item.meta ? h('text', { content: item.meta, fg: active ? undefined : 'muted' }) : null,
220
+ ...(renderItem
221
+ ? [h('box', { direction: 'column', flex: 1 }, renderItem(item, state))]
222
+ : defaultRow(item, active)),
146
223
  );
147
224
  }),
148
225
  );
149
- });
226
+ }
227
+
228
+ export const List = defineComponent('List', ListView as (props: ListProps) => RenderOutput) as typeof ListView;
229
+
230
+ /**
231
+ * The row you get for free: an icon, a title, an elaboration, a state.
232
+ *
233
+ * One line, because a list of a thousand of them has to cost what a list of
234
+ * ten does, and because most catalogues are exactly this. Anything else is
235
+ * `renderItem`.
236
+ */
237
+ function defaultRow(item: ListItem, active: boolean): RenderOutput[] {
238
+ return [
239
+ item.icon ? h('text', { content: item.icon, fg: item.tone ? TONE[item.tone] : undefined }) : null,
240
+ h('text', { content: item.label, flex: 1, truncate: 'end' }),
241
+ // The secondary columns keep the row's colour once it is selected;
242
+ // `muted` on a selected background is unreadable.
243
+ //
244
+ // The description yields first, and by a lot: it is the elaboration,
245
+ // and a row reading "brb_fram…" beside a status cut to "waiting on y…"
246
+ // has spent the width on the wrong two things. `meta` gives up none of
247
+ // it - a status is three words at most, and it is the column the row
248
+ // is being scanned for.
249
+ item.description
250
+ ? h('text', { content: item.description, fg: active ? undefined : 'muted', truncate: 'end', shrink: 8 })
251
+ : null,
252
+ item.meta
253
+ ? h('text', { content: item.meta, fg: active ? undefined : 'muted', shrink: 0 })
254
+ : null,
255
+ ] as RenderOutput[];
256
+ }
@@ -51,6 +51,9 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
51
51
  bullet: theme.glyphs.bulletFilled,
52
52
  quoteBar: theme.borderChars().left,
53
53
  ruled,
54
+ // How many rules a table gets is the theme's, but *where* they go is a
55
+ // question about rows, so it is answered in the layout rather than here.
56
+ tableRules: theme.tableRules,
54
57
  }),
55
58
  [given, content, width, theme, ruled],
56
59
  );
@@ -120,6 +123,59 @@ export const MarkdownView = defineComponent<MarkdownViewProps>('MarkdownView', (
120
123
  continue;
121
124
  }
122
125
 
126
+ if (row.kind === 'table') {
127
+ /*
128
+ * A table is ruled even where nothing else is.
129
+ *
130
+ * `theme.border` being `none` is a theme saying "do not box things" -
131
+ * panels, fences, dialogs. It is not saying "do not tell these columns
132
+ * apart": the rules in a table are its structure, not its decoration,
133
+ * and without them it is text that happens to line up, with nothing to
134
+ * say where it ended. So a borderless theme still gets a table, drawn
135
+ * in the plainest set there is.
136
+ *
137
+ * Asked for by name rather than taken from `borderChars()`, which is
138
+ * the same request routed through the terminal's own limits: on a
139
+ * console that cannot draw box characters this comes back as `+`, `-`
140
+ * and `|` rather than as something it would print as a row of boxes.
141
+ */
142
+ const chars = theme.borderChars(theme.border === 'none' ? 'single' : theme.border);
143
+ const fg = 'borderSubtle';
144
+
145
+ if (row.part !== 'head' && row.part !== 'body') {
146
+ const [left, joint, right] =
147
+ row.part === 'top' ? [chars.topLeft, chars.teeTop, chars.topRight]
148
+ : row.part === 'bottom' ? [chars.bottomLeft, chars.teeBottom, chars.bottomRight]
149
+ : [chars.teeRight, chars.cross, chars.teeLeft];
150
+ const bar = row.part === 'bottom' ? chars.bottom : chars.top;
151
+ // Every column plus the space either side of it, which is what the
152
+ // cell rows spend - an edge measured off the cells alone is an edge
153
+ // two cells short per column.
154
+ out.push(h('text', {
155
+ key,
156
+ content: left
157
+ + row.widths.map((w) => repeatToWidth(bar, w + 2)).join(joint)
158
+ + right,
159
+ fg,
160
+ wrap: 'none',
161
+ }));
162
+ i++;
163
+ continue;
164
+ }
165
+
166
+ // The cells arrive padded to their column, so nothing here measures
167
+ // anything: a row is its cells with a rule between each pair.
168
+ out.push(h('box', { key, direction: 'row', overflow: 'hidden' },
169
+ ...row.cells.flatMap((cell, c) => [
170
+ h('text', { key: `s${c}`, content: c === 0 ? `${chars.left} ` : ` ${chars.left} `, fg, wrap: 'none' }),
171
+ h('box', { key: `c${c}`, direction: 'row', overflow: 'hidden' },
172
+ ...runNodes(cell, quiet ? { fg: 'muted' as StyleColor } : {})),
173
+ ]),
174
+ h('text', { key: 'end', content: ` ${chars.right}`, fg, wrap: 'none' })));
175
+ i++;
176
+ continue;
177
+ }
178
+
123
179
  if (row.kind === 'rule') {
124
180
  out.push(h('box', { key, height: 1, fill: theme.borderChars().top, fg: 'borderSubtle' }));
125
181
  i++;
@@ -6,13 +6,35 @@ import { defineComponent, h, useEffect, useMeasure, useTheme } from '@textui/cor
6
6
  *
7
7
  * Through a ref rather than state, deliberately: a height arriving must not
8
8
  * schedule a render, or every measurement would cause the next measurement.
9
+ *
10
+ * It reports its `index` to one shared handler rather than taking a closure
11
+ * that already knows it. A closure per entry is a new prop on every entry
12
+ * every time the feed itself re-renders, and a changed prop is a re-render -
13
+ * so a feed that redrew for its own reasons dragged every entry it holds
14
+ * along with it, however little any of them had changed.
9
15
  */
10
- export const FeedEntry = defineComponent<BoxProps & { onHeight(height: number): void }>(
16
+ export const FeedEntry = defineComponent<BoxProps & {
17
+ index: number;
18
+ /**
19
+ * Standing in for an entry that is not drawn, at the height it had.
20
+ *
21
+ * A stand-in must not answer the question it is an answer to. Its height is
22
+ * one the feed already knows - it is where it came from - and reporting it
23
+ * back is how a measurement gets to depend on itself: a round where a
24
+ * stand-in came out any shorter would teach the feed that it *was* shorter,
25
+ * and the next round would place the rest of them on top of each other.
26
+ */
27
+ placeholder?: boolean;
28
+ onHeight(index: number, height: number): void;
29
+ }>(
11
30
  'FeedEntry',
12
31
  (props) => {
13
- const { onHeight, children, ...rest } = props;
32
+ const { index, placeholder, onHeight, children, ...rest } = props;
14
33
  const measured = useMeasure();
15
- useEffect(() => { onHeight(measured.height); }, [measured.height]);
34
+ useEffect(() => {
35
+ if (placeholder) return;
36
+ onHeight(index, measured.height);
37
+ }, [measured.height, placeholder]);
16
38
  return h('box', { direction: 'column', ...rest }, children);
17
39
  },
18
40
  );