@botiverse/raft-ui 0.15.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.16.0](https://github.com/botiverse/raft-design/compare/v0.15.0...v0.16.0) (2026-10-21)
2
+
3
+ ### Breaking Changes
4
+
5
+ - **VirtualList** `items`: compared by reference. A row renders again only when its item, its index or one of the list's render props changes, so replace an item to change it instead of editing it in place. ([#658](https://github.com/botiverse/raft-design/pull/658))
6
+ - Why: keeping rows across range updates is what removes the per-frame re-render.
7
+ - Migrate: if your code edits an item object in place, or a render function reads mutable state outside the props, replace the item (a new object) when it changes.
8
+ - Docs: https://raft-design.botiverse.build/components/virtual-list/
9
+
10
+ ### Changed
11
+
12
+ - **VirtualList**: scrolling no longer restyles or re-renders every mounted row. Rows that stay in range keep their elements; rows are mounted ahead of the view in batches and passed rows stay mounted for a while; only the rows that fill the view below are mounted; rows above the view are planned at the average measured height; resize passes where nothing changed size write nothing. ([#658](https://github.com/botiverse/raft-design/pull/658))
13
+ - Why: in a long chat timeline the list dropped more frames than a plain list while scrolling, most with a slow CPU: every row was restyled on almost every frame, and each range change re-rendered every row.
14
+ - Affects: with a 4x slower CPU, frames over 33ms scrolling 320 messages 9 → 0 down and 24 → 0 up, p95 33ms → 16.7ms; main-thread time per frame 10–17ms → 2–5ms. Up to 2.5 viewports of passed rows stay mounted; after a jump only the rows in view mount, as before.
15
+ - Docs: https://raft-design.botiverse.build/components/virtual-list/
16
+
1
17
  ## [0.15.0](https://github.com/botiverse/raft-design/compare/v0.14.0...v0.15.0) (2026-10-21)
2
18
 
3
19
  ### Design System
package/dist/index.d.mts CHANGED
@@ -4387,11 +4387,17 @@ interface VirtualListProps<T> extends VirtualListNativeProps {
4387
4387
  * result is the row's `renderedItemWithCols`.
4388
4388
  */
4389
4389
  renderItemWithCols?: ((info: RenderItemWithColsInfo) => ReactNode) | undefined;
4390
- /** The items, one row each, in order. */
4390
+ /**
4391
+ * The items, one row each, in order. Treat them as immutable: a mounted row is rendered again only
4392
+ * when its item (by reference) or its index changes, or a render prop does (`children`,
4393
+ * `ItemComponent`, `getKey`, `renderCol`, `renderItemWithCols`, `itemResizable`, `ignoreItemResize`,
4394
+ * `sessionId`, `virtual`), so replace an item's object to change its row.
4395
+ */
4391
4396
  items?: readonly T[] | undefined;
4392
4397
  /**
4393
- * The shortest a row can be, in px, or less: the list mounts as many rows as fit in the view at this
4394
- * size, plus one, so a larger value leaves the end of the view empty.
4398
+ * The shortest a row can be, in px, or less: to fill the view, rows not measured yet count at this
4399
+ * size (after a jump, as many rows as fit at it, plus one), so a larger value leaves the end of the
4400
+ * view empty. While the list scrolls, rows ahead of the view are mounted on top of these.
4395
4401
  */
4396
4402
  itemMinSize: number;
4397
4403
  /** The size, in px, counted for a row not measured yet (its offset and the spacer's height). */
@@ -4450,6 +4456,8 @@ interface VirtualListProps<T> extends VirtualListNativeProps {
4450
4456
  /**
4451
4457
  * Renders a row: called for each item in or near the view with the item, its index, the `domRef` to
4452
4458
  * put on the row's root element and the `style` to spread on it. Give the element the `key` it gets.
4459
+ * A mounted row is rendered again only when its item, its index or a render prop changes (see
4460
+ * `items`): read no other mutable state here.
4453
4461
  */
4454
4462
  children?: ((props: ItemRenderProps<T> & {
4455
4463
  key: string | undefined;
package/dist/index.mjs CHANGED
@@ -13399,10 +13399,45 @@ function toLayoutUnit(value) {
13399
13399
  return Math.round(value * 64) / 64;
13400
13400
  }
13401
13401
  //#endregion
13402
+ //#region src/components/virtual-list/utils/size-cache.ts
13403
+ /**
13404
+ * Records `size` as the measured size of row `key` in `offsets`, kept even when it equals the estimate
13405
+ * (an offset of 0), so the row counts as measured. Returns whether the cache changed.
13406
+ */
13407
+ function recordMeasuredSize(offsets, key, size, itemEstimatedSize) {
13408
+ const offset = size - itemEstimatedSize;
13409
+ if (offsets.get(key) === offset) return false;
13410
+ offsets.set(key, offset);
13411
+ return true;
13412
+ }
13413
+ function createTypicalSizeCache() {
13414
+ return {
13415
+ version: -1,
13416
+ itemEstimatedSize: NaN,
13417
+ size: 0
13418
+ };
13419
+ }
13420
+ /**
13421
+ * The size rows typically are: the mean size of the measured rows (`offsets`), or `itemEstimatedSize`
13422
+ * before any is measured, and at least `itemMinSize` (and 1). Kept in `cache` and computed again only
13423
+ * when `version` (bumped whenever `offsets` changes) or `itemEstimatedSize` changes.
13424
+ */
13425
+ function getTypicalSize(cache, offsets, version, itemEstimatedSize, itemMinSize) {
13426
+ if (cache.version !== version || cache.itemEstimatedSize !== itemEstimatedSize) {
13427
+ let total = 0;
13428
+ for (const offset of offsets.values()) total += offset;
13429
+ cache.size = itemEstimatedSize + (offsets.size === 0 ? 0 : total / offsets.size);
13430
+ cache.version = version;
13431
+ cache.itemEstimatedSize = itemEstimatedSize;
13432
+ }
13433
+ return Math.max(cache.size, itemMinSize, 1);
13434
+ }
13435
+ //#endregion
13402
13436
  //#region src/components/virtual-list/utils/range.ts
13403
13437
  /**
13404
13438
  * The items to render: from the item at `scrollTop - paddingTop` through as many items as fit in
13405
- * `listHeight` at their minimum size, plus one (no overscan). Empty (`0..-1`) before the list has been
13439
+ * `listHeight` at their minimum size, plus one (the overscan is `getOverscannedRange`'s). Empty
13440
+ * (`0..-1`) before the list has been
13406
13441
  * measured or when there are no items.
13407
13442
  */
13408
13443
  function getVerticalRange({ fenwickTree, scrollTop, listHeight, itemMinSize, itemCount, paddingTop }) {
@@ -13416,6 +13451,60 @@ function getVerticalRange({ fenwickTree, scrollTop, listHeight, itemMinSize, ite
13416
13451
  endIndex: Math.min(startIndex + Math.ceil(listHeight / itemMinSize + 1), itemCount - 1)
13417
13452
  };
13418
13453
  }
13454
+ const OVERSCAN_MIN = .25;
13455
+ const OVERSCAN_TARGET = 1;
13456
+ const OVERSCAN_MAX = 2.5;
13457
+ const OVERSCAN_BELOW = .25;
13458
+ /**
13459
+ * The last row needed to fill the view from row `startIndex` down: measured rows (`isMeasured`) count at
13460
+ * their size, the others at `itemMinSize`, so the view is filled whatever the unmeasured rows turn out
13461
+ * to be. `viewTop` is the view's top in item offsets.
13462
+ */
13463
+ function getNeededEnd({ fenwickTree, startIndex, viewTop, listHeight, itemMinSize, itemCount, isMeasured }) {
13464
+ const bottom = viewTop + listHeight;
13465
+ let offset = fenwickTree.sum(startIndex);
13466
+ let index = startIndex;
13467
+ for (; index < itemCount - 1; ++index) {
13468
+ offset += isMeasured(index) ? fenwickTree.get(index) : Math.max(itemMinSize, 1);
13469
+ if (offset >= bottom) break;
13470
+ }
13471
+ return index;
13472
+ }
13473
+ /**
13474
+ * The items to render with overscan (see above), from the range in view (`view`, as `getVerticalRange`
13475
+ * gives it), the last row needed to fill the view (`neededEnd`) and the rendered range (`prev`).
13476
+ * `viewTop` is the view's top in item offsets, `moved` how far it moved since the last range update,
13477
+ * `movingUp` whether it last moved up, `settling` whether a jump is settling, `plannedSize` the size a
13478
+ * row is counted at when planning how many rows to mount above the view. With a `listHeight` of 0
13479
+ * (not laid out) there is no overscan: `view` is returned.
13480
+ */
13481
+ function getOverscannedRange({ fenwickTree, view, prev, neededEnd, viewTop, moved, movingUp, settling, plannedSize, listHeight, itemCount }) {
13482
+ const last = itemCount - 1;
13483
+ const min = OVERSCAN_MIN * listHeight;
13484
+ const target = OVERSCAN_TARGET * listHeight;
13485
+ const max = OVERSCAN_MAX * listHeight;
13486
+ const apart = view.startIndex > prev.endIndex || view.endIndex < prev.startIndex;
13487
+ if (listHeight <= 0 || view.endIndex < view.startIndex || prev.endIndex < prev.startIndex || prev.endIndex > last || moved > max || apart && moved === 0) return view;
13488
+ let ahead = view.startIndex;
13489
+ for (let above = viewTop - fenwickTree.sum(ahead); ahead > 0 && above < target;) {
13490
+ ahead -= 1;
13491
+ above += plannedSize(ahead);
13492
+ }
13493
+ let startIndex = prev.startIndex;
13494
+ if (prev.startIndex > view.startIndex) startIndex = movingUp && !settling ? ahead : view.startIndex;
13495
+ else if (!settling) {
13496
+ const above = viewTop - fenwickTree.sum(prev.startIndex);
13497
+ if (movingUp ? above < min : above > max) startIndex = movingUp ? Math.min(ahead, prev.startIndex) : ahead;
13498
+ }
13499
+ const needed = Math.min(neededEnd, view.endIndex);
13500
+ const neededBottom = fenwickTree.sum(needed + 1);
13501
+ let endIndex = prev.endIndex;
13502
+ if (prev.endIndex < needed || movingUp && !settling && fenwickTree.sum(prev.endIndex + 1) - neededBottom > max) endIndex = Math.min(Math.max(fenwickTree.getBound(neededBottom + OVERSCAN_BELOW * listHeight), needed), last);
13503
+ return {
13504
+ startIndex,
13505
+ endIndex
13506
+ };
13507
+ }
13419
13508
  /** The columns to render: the ones in `scrollLeft .. scrollLeft + listWidth`, plus one on each side. */
13420
13509
  function getHorizontalRange({ xFenwickTree, scrollLeft, listWidth, columnCount }) {
13421
13510
  if (xFenwickTree === null || columnCount === 0) return {
@@ -13866,7 +13955,11 @@ function VirtualList(props) {
13866
13955
  const keptKeysRef = useRef([]);
13867
13956
  /** The key of the row that holds focus (`document.activeElement` is in it), tracked by focusin / focusout. */
13868
13957
  const focusedKeyRef = useRef(null);
13958
+ /** The measured rows' sizes, by key (see `utils/size-cache.ts`). */
13869
13959
  const keyToHeightOffsetRef = useRef(/* @__PURE__ */ new Map());
13960
+ /** Bumped whenever `keyToHeightOffsetRef` changes (see `getTypicalSize`). */
13961
+ const measuredVersionRef = useRef(0);
13962
+ const typicalSizeRef = useRef(createTypicalSizeCache());
13870
13963
  const leadingMarginRef = useRef(0);
13871
13964
  /**
13872
13965
  * Where the first mounted row in flow starts in the visible-items element (its top margin there,
@@ -13877,6 +13970,8 @@ function VirtualList(props) {
13877
13970
  const pendingScrollTaskTimeoutRef = useRef(null);
13878
13971
  const pendingScrollTaskRafRef = useRef(null);
13879
13972
  const wheelScrollingRef = useRef(false);
13973
+ /** Whether the view last moved up (see `updateRange`). */
13974
+ const scrollingUpRef = useRef(false);
13880
13975
  /** Clears `wheelScrollingRef` in the next frame; this list's own, so other lists cannot replace it. */
13881
13976
  const [runLatestInNextFrame] = useState(createLatestInNextFrame);
13882
13977
  /** The wheel is only taken over (catchWheel) where the pointer is not coarse. */
@@ -13944,6 +14039,7 @@ function VirtualList(props) {
13944
14039
  keptKeysRef.current = [];
13945
14040
  focusedKeyRef.current = null;
13946
14041
  keyToHeightOffsetRef.current.clear();
14042
+ measuredVersionRef.current += 1;
13947
14043
  leadingMarginRef.current = 0;
13948
14044
  keyToIndexRef.current = /* @__PURE__ */ new Map();
13949
14045
  rangeRef.current = {
@@ -14135,9 +14231,12 @@ function VirtualList(props) {
14135
14231
  }
14136
14232
  }
14137
14233
  }, [virtual]);
14234
+ /** The size rows typically are: the mean of the measured rows (`getTypicalSize`). */
14235
+ const getTypicalRowSize = useEventCallback(() => getTypicalSize(typicalSizeRef.current, keyToHeightOffsetRef.current, measuredVersionRef.current, itemEstimatedSize, itemMinSize));
14138
14236
  /**
14139
14237
  * The viewport sync. The range starts at the item at `scrollTop + compensation - paddingTop` and
14140
- * holds as many items as fit at `itemMinSize`, plus one. Items holding the text selection, and the item
14238
+ * holds as many items as fit at `itemMinSize`, plus one, and is overscanned in batches
14239
+ * (`getOverscannedRange`). Items holding the text selection, and the item
14141
14240
  * holding focus, stay
14142
14241
  * mounted ("kept"). When the new range is inside the old one, only the layout styles are written;
14143
14242
  * otherwise the items entering above the old start are recorded (`prependIndexes`) and the range is
@@ -14163,7 +14262,7 @@ function VirtualList(props) {
14163
14262
  startIndex: range.verticalStartIndex,
14164
14263
  endIndex: range.verticalEndIndex
14165
14264
  };
14166
- const nextRange = getVerticalRange({
14265
+ const viewRange = getVerticalRange({
14167
14266
  fenwickTree,
14168
14267
  scrollTop: effectiveScrollTop,
14169
14268
  listHeight: viewport.listHeight,
@@ -14171,8 +14270,37 @@ function VirtualList(props) {
14171
14270
  itemCount: items.length,
14172
14271
  paddingTop: getRowsTop()
14173
14272
  });
14273
+ const overscanned = viewport.listHeight > 0;
14274
+ if (scrollTop !== viewport.scrollTop) scrollingUpRef.current = scrollTop < viewport.scrollTop;
14275
+ const nextRange = overscanned ? getOverscannedRange({
14276
+ fenwickTree,
14277
+ view: viewRange,
14278
+ prev: prevRange,
14279
+ neededEnd: getNeededEnd({
14280
+ fenwickTree,
14281
+ startIndex: viewRange.startIndex,
14282
+ viewTop: effectiveScrollTop - getRowsTop(),
14283
+ listHeight: viewport.listHeight,
14284
+ itemMinSize,
14285
+ itemCount: items.length,
14286
+ isMeasured: (index) => {
14287
+ const item = items[index];
14288
+ return item !== void 0 && keyToHeightOffsetRef.current.has(getKey(item));
14289
+ }
14290
+ }),
14291
+ viewTop: effectiveScrollTop - getRowsTop(),
14292
+ moved: Math.abs(scrollTop - viewport.scrollTop),
14293
+ movingUp: scrollingUpRef.current,
14294
+ settling: pendingScrollTaskRef.current !== null,
14295
+ plannedSize: (index) => {
14296
+ const item = items[index];
14297
+ return item !== void 0 && keyToHeightOffsetRef.current.has(getKey(item)) ? fenwickTree.get(index) : getTypicalRowSize();
14298
+ },
14299
+ listHeight: viewport.listHeight,
14300
+ itemCount: items.length
14301
+ }) : viewRange;
14174
14302
  const rangeChanged = prevRange.startIndex !== nextRange.startIndex || prevRange.endIndex !== nextRange.endIndex;
14175
- const nextInsidePrev = nextRange.startIndex >= prevRange.startIndex && prevRange.endIndex >= nextRange.endIndex;
14303
+ const nextInsidePrev = !overscanned && nextRange.startIndex >= prevRange.startIndex && prevRange.endIndex >= nextRange.endIndex;
14176
14304
  const prevColRange = {
14177
14305
  startIndex: range.horizontalStartIndex,
14178
14306
  endIndex: range.horizontalEndIndex
@@ -14406,9 +14534,8 @@ function VirtualList(props) {
14406
14534
  rows.forEach(({ key, index }, i) => {
14407
14535
  const size = spans[i];
14408
14536
  const previousSize = fenwickTree.get(index);
14537
+ if (recordMeasuredSize(keyToHeightOffsetRef.current, key, size, itemEstimatedSize)) measuredVersionRef.current += 1;
14409
14538
  if (size === previousSize) return;
14410
- if (size - itemEstimatedSize === 0) keyToHeightOffsetRef.current.delete(key);
14411
- else keyToHeightOffsetRef.current.set(key, size - itemEstimatedSize);
14412
14539
  fenwickTree.add(index, size - previousSize);
14413
14540
  resizes.push({
14414
14541
  index,
@@ -14728,6 +14855,10 @@ function VirtualList(props) {
14728
14855
  const isFirstOfKey = /* @__PURE__ */ new Map();
14729
14856
  for (const { key, isFirst } of queue) if (!isFirstOfKey.has(key)) isFirstOfKey.set(key, isFirst);
14730
14857
  const { resizes, leadingMarginDelta } = measureFlowRun();
14858
+ if (resizes.length === 0 && leadingMarginDelta === 0 && pendingScrollTaskRef.current === null) {
14859
+ clearItemResizeQueue();
14860
+ return;
14861
+ }
14731
14862
  applyLayoutStyles(startIndex);
14732
14863
  onContentResize?.();
14733
14864
  const listEl = listElRef.current;
@@ -15001,7 +15132,45 @@ function VirtualList(props) {
15001
15132
  ItemComponent,
15002
15133
  renderItemChild
15003
15134
  ]);
15004
- const renderedVisibleItems = useMemo(() => visibleItems.map((item) => renderItem(item, false)), [renderItem, visibleItems]);
15135
+ const renderInputs = useMemo(() => ({}), [
15136
+ virtual,
15137
+ getKey,
15138
+ renderCol,
15139
+ renderItemWithCols,
15140
+ itemResizable,
15141
+ sessionId,
15142
+ ignoreItemResize,
15143
+ ItemComponent,
15144
+ renderItemChild
15145
+ ]);
15146
+ const [flowElements] = useState(() => /* @__PURE__ */ new Map());
15147
+ const renderedVisibleItems = useMemo(() => {
15148
+ const nodes = visibleItems.map((item) => {
15149
+ const key = getKey(item);
15150
+ const index = keyToIndexRef.current.get(key);
15151
+ const cached = flowElements.get(key);
15152
+ if (cached !== void 0 && cached.item === item && cached.index === index && cached.inputs === renderInputs) return cached.node;
15153
+ const node = renderItem(item, false);
15154
+ flowElements.set(key, {
15155
+ item,
15156
+ index,
15157
+ inputs: renderInputs,
15158
+ node
15159
+ });
15160
+ return node;
15161
+ });
15162
+ if (flowElements.size > visibleItems.length) {
15163
+ const inRange = new Set(visibleItems.map(getKey));
15164
+ for (const key of flowElements.keys()) if (!inRange.has(key)) flowElements.delete(key);
15165
+ }
15166
+ return nodes;
15167
+ }, [
15168
+ renderItem,
15169
+ visibleItems,
15170
+ getKey,
15171
+ flowElements,
15172
+ renderInputs
15173
+ ]);
15005
15174
  const renderedKeptBefore = useMemo(() => keptItemsBefore.map((item) => renderItem(item, true)), [renderItem, keptItemsBefore]);
15006
15175
  const renderedKeptAfter = useMemo(() => keptItemsAfter.map((item) => renderItem(item, true)), [renderItem, keptItemsAfter]);
15007
15176
  useNonVirtualContentResize(itemsElRef, onContentResize, !virtual);
@@ -15045,9 +15214,7 @@ function VirtualList(props) {
15045
15214
  const { spans, firstStart } = measureRowSpans(visibleItemsEl, rows.map((row) => row.el));
15046
15215
  if (rows[0] !== void 0 && keyToIndexRef.current.get(rows[0].key) === 0) leadingMarginRef.current = firstStart;
15047
15216
  rows.forEach(({ key }, i) => {
15048
- const offset = spans[i] - itemEstimatedSize;
15049
- if (offset === 0) keyToHeightOffsetRef.current.delete(key);
15050
- else keyToHeightOffsetRef.current.set(key, offset);
15217
+ if (recordMeasuredSize(keyToHeightOffsetRef.current, key, spans[i], itemEstimatedSize)) measuredVersionRef.current += 1;
15051
15218
  });
15052
15219
  }
15053
15220
  }
package/dist/wip.mjs CHANGED
@@ -27,17 +27,20 @@ function getSnapshot() {
27
27
  function usePhoneMedia() {
28
28
  return useSyncExternalStore(subscribe, getSnapshot, () => false);
29
29
  }
30
+ //#endregion
31
+ //#region src/components/combobox-select/combobox-select.list-side.ts
32
+ /** One option row (36px) and the list's 6px padding above and below. */
33
+ const ROW = 36;
34
+ const LIST_PADDING = 12;
35
+ /** The gap between the field and the list (the positioner's sideOffset). */
36
+ const SIDE_OFFSET = 4;
30
37
  /**
31
- * The side a phone's list opens on, chosen once when it opens so it never flips
32
- * while it is open (a phone's visual viewport keeps changing under it, as the
33
- * keyboard and the address bar come and go). Below, unless the room below is
34
- * short of the list and there is more above. `listHeight` is the list's full
35
- * height and `maxHeight` its cap (`listMaxHeight`), both in pixels.
38
+ * Whether fewer than three rows of a phone's list fit between the field and the
39
+ * bottom of the visual viewport. The list always opens below the field on a
40
+ * phone and never flips; when this is true the field is scrolled up instead.
36
41
  */
37
- function preferredListSide(anchor, viewport, listHeight, maxHeight) {
38
- const below = viewport.top + viewport.height - anchor.bottom;
39
- const above = anchor.top - viewport.top;
40
- return below >= Math.min(listHeight, maxHeight) || below >= above ? "bottom" : "top";
42
+ function roomBelowIsShort(field, viewport, rows = 3) {
43
+ return viewport.top + viewport.height - field.bottom - SIDE_OFFSET < rows * ROW + LIST_PADDING;
41
44
  }
42
45
  //#endregion
43
46
  //#region src/components/combobox-select/combobox-select.options.ts
@@ -106,37 +109,41 @@ function renderSelectedChip({ record, index, tagLimit, strings, renderLabel, ren
106
109
  }, optionKey(record.value));
107
110
  }
108
111
  /**
109
- * On a phone the list keeps the side it opened on: it never flips while it is
110
- * open, as the keyboard or the address bar change the visual viewport under it.
112
+ * On a phone the list always opens below the field, between the field and the
113
+ * keyboard, as a native autocomplete does, and never flips: which side has more
114
+ * room depends on whether the keyboard happens to be up, so choosing a side made
115
+ * the same field open up one time and down the next.
111
116
  */
112
117
  const PHONE_LIST_COLLISION = {
113
118
  side: "none",
114
119
  align: "shift",
115
120
  fallbackAxisSide: "none"
116
121
  };
117
- /** A CSS length in pixels, as the document resolves it (rem, vh, calc()). */
118
- function resolveLength(doc, length) {
119
- const probe = doc.createElement("div");
120
- probe.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;width:0";
121
- probe.style.height = length;
122
- doc.body.append(probe);
123
- const height = probe.getBoundingClientRect().height;
124
- probe.remove();
125
- return height;
126
- }
127
- /** The side the phone's list opens on, from the room around the field and the list's cap. */
128
- function measureListSide(anchor, groups, maxHeight) {
129
- const view = anchor?.ownerDocument.defaultView;
130
- if (!anchor || !view) return "bottom";
122
+ /**
123
+ * Keeps room for the list below the field on a phone: when fewer than three
124
+ * rows fit between the field and the bottom of the visual viewport (the
125
+ * keyboard's top edge while it is up), scrolls the field up to the top of
126
+ * what is visible instead of flipping the list above it.
127
+ */
128
+ function keepRoomBelow(field) {
129
+ const view = field?.ownerDocument.defaultView;
130
+ if (!field || !view) return;
131
131
  const visual = view.visualViewport;
132
- const rows = groups.reduce((count, group) => count + group.items.length + (group.label == null ? 0 : 1), 0);
133
- return preferredListSide(anchor.getBoundingClientRect(), visual ? {
132
+ const viewport = visual ? {
134
133
  top: visual.offsetTop,
135
134
  height: visual.height
136
135
  } : {
137
136
  top: 0,
138
137
  height: view.innerHeight
139
- }, rows * 36 + 12, resolveLength(anchor.ownerDocument, maxHeight));
138
+ };
139
+ if (roomBelowIsShort(field.getBoundingClientRect(), viewport)) {
140
+ if (!field.style.scrollMarginTop) field.style.scrollMarginTop = "2.5rem";
141
+ field.scrollIntoView({
142
+ block: "start",
143
+ inline: "nearest",
144
+ behavior: "instant"
145
+ });
146
+ }
140
147
  }
141
148
  /** Options-driven composition. The stable compound Combobox API stays unchanged. */
142
149
  function isItemEqualToValue(a, b) {
@@ -422,17 +429,27 @@ function ComboboxSelect({ options, multiple, value, defaultValue, onValueChange,
422
429
  const valueChange = handleValueChange;
423
430
  const listHeight = typeof listMaxHeight === "number" ? `${listMaxHeight}px` : listMaxHeight;
424
431
  const fieldRef = useRef(null);
425
- const [listSide, setListSide] = useState("bottom");
426
- const groups = normalized.groups;
432
+ const [phoneOpen, setPhoneOpen] = useState(false);
427
433
  const handleOpenChange = useCallback((next, details) => {
428
434
  onOpenChange?.(next, details);
429
- if (next && phone && !details.isCanceled) setListSide(measureListSide(fieldRef.current, groups, listHeight ?? "16rem"));
430
- }, [
431
- onOpenChange,
432
- phone,
433
- groups,
434
- listHeight
435
- ]);
435
+ if (phone && !details.isCanceled) setPhoneOpen(next);
436
+ }, [onOpenChange, phone]);
437
+ useEffect(() => {
438
+ if (!phone || !phoneOpen) return;
439
+ const field = fieldRef.current;
440
+ const visual = field?.ownerDocument.defaultView?.visualViewport;
441
+ let frame = 0;
442
+ const check = () => {
443
+ cancelAnimationFrame(frame);
444
+ frame = requestAnimationFrame(() => keepRoomBelow(field));
445
+ };
446
+ check();
447
+ visual?.addEventListener("resize", check);
448
+ return () => {
449
+ cancelAnimationFrame(frame);
450
+ visual?.removeEventListener("resize", check);
451
+ };
452
+ }, [phone, phoneOpen]);
436
453
  const popupPropsStyle = popupProps?.style;
437
454
  const popupStyle = useMemo(() => {
438
455
  if (listHeight === void 0) return popupPropsStyle;
@@ -473,7 +490,7 @@ function ComboboxSelect({ options, multiple, value, defaultValue, onValueChange,
473
490
  align: "start",
474
491
  collisionPadding: 8,
475
492
  ...phone ? {
476
- side: listSide,
493
+ side: "bottom",
477
494
  collisionAvoidance: PHONE_LIST_COLLISION
478
495
  } : null,
479
496
  ...positionerProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botiverse/raft-ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "React components and CSS foundations for the Raft Design System.",
5
5
  "keywords": [
6
6
  "agent-skills",
@@ -86,6 +86,7 @@
86
86
  "react-dom": "^19"
87
87
  },
88
88
  "scripts": {
89
+ "bench:virtual-list": "node bench/virtual-list/run.mjs",
89
90
  "build:tokens": "node scripts/build-tokens.mjs",
90
91
  "build": "vp pack",
91
92
  "check": "vp check",