fix: allow manual virtual tree scrolling

This commit is contained in:
2026-08-20 01:54:21 +00:00
parent 7d484a3998
commit 43892865c3
2 changed files with 44 additions and 5 deletions
+7 -5
View File
@@ -155,7 +155,7 @@
return state.searchQuery && state.searchExpandedKeys ? state.searchExpandedKeys : state.expandedKeys; return state.searchQuery && state.searchExpandedKeys ? state.searchExpandedKeys : state.expandedKeys;
} }
function commit() { function commit(options = {}) {
const rows = treeApi.flattenVisibleRows(model, { const rows = treeApi.flattenVisibleRows(model, {
expandedKeys: effectiveExpandedKeys(), expandedKeys: effectiveExpandedKeys(),
selectedKey: state.selectedKey, selectedKey: state.selectedKey,
@@ -163,10 +163,11 @@
}); });
const activeIndex = rows.findIndex((row) => row.key === state.activeKey); const activeIndex = rows.findIndex((row) => row.key === state.activeKey);
const maximumScrollTop = Math.max(0, (rows.length * rowHeight) - state.viewportHeight); const maximumScrollTop = Math.max(0, (rows.length * rowHeight) - state.viewportHeight);
if (state.scrollTop > maximumScrollTop) { const clampedScrollTop = Math.max(0, Math.min(state.scrollTop, maximumScrollTop));
state = Object.assign({}, state, { scrollTop: maximumScrollTop }); if (clampedScrollTop !== state.scrollTop) {
state = Object.assign({}, state, { scrollTop: clampedScrollTop });
} }
if (activeIndex >= 0 && state.viewportHeight > 0) { if (options.ensureActive === true && activeIndex >= 0 && state.viewportHeight > 0) {
const activeTop = activeIndex * rowHeight; const activeTop = activeIndex * rowHeight;
const activeBottom = activeTop + rowHeight; const activeBottom = activeTop + rowHeight;
let scrollTop = state.scrollTop; let scrollTop = state.scrollTop;
@@ -192,7 +193,8 @@
const next = reduceSidebar(state, safeAction, visibleRows); const next = reduceSidebar(state, safeAction, visibleRows);
if (next === state) return lastSnapshot; if (next === state) return lastSnapshot;
state = next; state = next;
return commit(); const ensureActive = safeAction.type === 'KEY' || safeAction.type === 'SET_ACTIVE';
return commit({ ensureActive });
} }
async function search(query) { async function search(query) {
+37
View File
@@ -140,6 +140,43 @@ test('first arrow press chooses the directional edge when no row is active', ()
assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowUp' }, rows).activeKey, rows.at(-1).key); assert.equal(reduceSidebar(createInitialState(), { type: 'KEY', key: 'ArrowUp' }, rows).activeKey, rows.at(-1).key);
}); });
test('manual viewport scrolling may virtualize selection until keyboard navigation restores the active row', () => {
const model = buildDeviceTree({
sites: [{ id: 'large', name: 'Large' }],
groups: [],
devices: Array.from({ length: 1500 }, (_, index) => ({
id: `c${String(index).padStart(4, '0')}`,
name: `Camera ${String(index).padStart(4, '0')}`,
siteId: 'large',
})),
});
const site = model.sites[0];
const group = site.groups[0];
const firstCamera = group.cameras[0];
const controller = createSidebarController({
model,
view: { commit() {} },
rowHeight: 20,
overscan: 0,
initialState: { expandedKeys: new Set([site.key, group.key]), viewportHeight: 100 },
});
controller.dispatch({ type: 'SET_SELECTION', rowKey: firstCamera.key });
controller.dispatch({ type: 'SET_VIEWPORT', scrollTop: 30000, viewportHeight: 100 });
let snapshot = controller.getSnapshot();
assert.equal(snapshot.state.selectedKey, firstCamera.key);
assert.equal(snapshot.state.activeKey, firstCamera.key);
assert.equal(snapshot.state.scrollTop, 29940);
assert.equal(snapshot.window.rows.some((row) => row.key === firstCamera.key), false);
assert.equal(snapshot.rows.hiddenSelected, false);
controller.dispatch({ type: 'KEY', key: 'Home' });
snapshot = controller.getSnapshot();
assert.equal(snapshot.state.activeKey, site.key);
assert.equal(snapshot.state.scrollTop, 0);
assert.equal(snapshot.window.rows.some((row) => row.key === site.key), true);
});
test('collapse clamps stale scroll and keyboard navigation scrolls active rows into the virtual window', () => { test('collapse clamps stale scroll and keyboard navigation scrolls active rows into the virtual window', () => {
const model = buildDeviceTree({ const model = buildDeviceTree({
sites: [{ id: 'large', name: 'Large' }], sites: [{ id: 'large', name: 'Large' }],