diff --git a/sidebar-controller.js b/sidebar-controller.js index ec802e0..4e55061 100644 --- a/sidebar-controller.js +++ b/sidebar-controller.js @@ -155,7 +155,7 @@ return state.searchQuery && state.searchExpandedKeys ? state.searchExpandedKeys : state.expandedKeys; } - function commit() { + function commit(options = {}) { const rows = treeApi.flattenVisibleRows(model, { expandedKeys: effectiveExpandedKeys(), selectedKey: state.selectedKey, @@ -163,10 +163,11 @@ }); const activeIndex = rows.findIndex((row) => row.key === state.activeKey); const maximumScrollTop = Math.max(0, (rows.length * rowHeight) - state.viewportHeight); - if (state.scrollTop > maximumScrollTop) { - state = Object.assign({}, state, { scrollTop: maximumScrollTop }); + const clampedScrollTop = Math.max(0, Math.min(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 activeBottom = activeTop + rowHeight; let scrollTop = state.scrollTop; @@ -192,7 +193,8 @@ const next = reduceSidebar(state, safeAction, visibleRows); if (next === state) return lastSnapshot; state = next; - return commit(); + const ensureActive = safeAction.type === 'KEY' || safeAction.type === 'SET_ACTIVE'; + return commit({ ensureActive }); } async function search(query) { diff --git a/test/sidebar-controller.test.js b/test/sidebar-controller.test.js index f60c88c..d558e74 100644 --- a/test/sidebar-controller.test.js +++ b/test/sidebar-controller.test.js @@ -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); }); +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', () => { const model = buildDeviceTree({ sites: [{ id: 'large', name: 'Large' }],