@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 +16 -0
- package/dist/index.d.mts +11 -3
- package/dist/index.mjs +177 -10
- package/dist/wip.mjs +55 -38
- package/package.json +2 -1
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
|
-
/**
|
|
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:
|
|
4394
|
-
* size, plus one, so a larger value leaves the end of the
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
38
|
-
|
|
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
|
|
110
|
-
*
|
|
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
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
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
|
|
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
|
-
}
|
|
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 [
|
|
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 (
|
|
430
|
-
}, [
|
|
431
|
-
|
|
432
|
-
phone
|
|
433
|
-
|
|
434
|
-
|
|
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:
|
|
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.
|
|
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",
|