fix: reduce height of scrollable list

This commit is contained in:
Jack Wotherspoon
2026-03-17 09:31:36 -04:00
parent 56e0865a7b
commit e2a0aabaa5
4 changed files with 88 additions and 67 deletions
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import {
@@ -82,35 +82,39 @@ export function BaseSelectionList<
priority,
});
const [scrollOffset, setScrollOffset] = useState(0);
const [scrollOffsetState, setScrollOffsetState] = useState(0);
// Handle scrolling for long lists
useEffect(() => {
const newScrollOffset = Math.max(
// Compute effective scroll offset synchronously during render to avoid flicker
let scrollOffset = scrollOffsetState;
if (activeIndex < scrollOffset) {
scrollOffset = activeIndex;
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
scrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
}
const maxScroll = Math.max(0, items.length - maxItemsToShow);
if (scrollOffset > maxScroll) {
scrollOffset = maxScroll;
}
// Update state to match derived value if it changed
if (scrollOffsetState !== scrollOffset) {
setScrollOffsetState(scrollOffset);
}
const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow);
const numberColumnWidth = String(items.length).length;
const canScrollUp = scrollOffset > 0;
const canScrollDown = scrollOffset + maxItemsToShow < items.length;
const hasScrollArrows = showScrollArrows && items.length > maxItemsToShow;
return (
<Box flexDirection="column">
{/* Use conditional coloring instead of conditional rendering */}
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={scrollOffset > 0 ? theme.text.primary : theme.text.secondary}
>
</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isSelected = activeIndex === itemIndex;
@@ -139,19 +143,35 @@ export function BaseSelectionList<
numberColumnWidth,
)}.`;
// Determine the indicator character for the radio column
let indicator = ' ';
let indicatorColor = theme.text.primary;
if (isSelected) {
indicator = selectedIndicator;
indicatorColor = theme.ui.focus;
} else if (hasScrollArrows && index === 0 && canScrollUp) {
indicator = '▲';
indicatorColor = theme.text.secondary;
} else if (
hasScrollArrows &&
index === visibleItems.length - 1 &&
canScrollDown
) {
indicator = '▼';
indicatorColor = theme.text.secondary;
}
return (
<Box
key={item.key}
alignItems="flex-start"
backgroundColor={isSelected ? theme.background.focus : undefined}
>
{/* Radio button indicator */}
{/* Radio button indicator (also shows scroll arrows inline) */}
<Box minWidth={2} flexShrink={0}>
<Text
color={isSelected ? theme.ui.focus : theme.text.primary}
aria-hidden
>
{isSelected ? selectedIndicator : ' '}
<Text color={indicatorColor} aria-hidden>
{indicator}
</Text>
</Box>
@@ -178,18 +198,6 @@ export function BaseSelectionList<
</Box>
);
})}
{showScrollArrows && items.length > maxItemsToShow && (
<Text
color={
scrollOffset + maxItemsToShow < items.length
? theme.text.primary
: theme.text.secondary
}
>
</Text>
)}
</Box>
);
}
@@ -172,11 +172,12 @@ export function SearchableList<T extends GenericListItem>({
item: T,
isActive: boolean,
labelWidth: number,
arrowChar?: string,
) => (
<Box flexDirection="row" alignItems="flex-start">
<Box minWidth={2} flexShrink={0}>
<Text color={isActive ? theme.status.success : theme.text.secondary}>
{isActive ? '●' : ''}
{isActive ? '●' : arrowChar || ' '}
</Text>
</Box>
<Box flexDirection="column" flexGrow={1} minWidth={0}>
@@ -226,26 +227,44 @@ export function SearchableList<T extends GenericListItem>({
</Box>
) : (
<>
{filteredItems.length > maxItemsToShow && (
<Box marginX={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
{visibleItems.map((item, index) => {
const isSelected = activeIndex === scrollOffset + index;
const hasScrollArrows = filteredItems.length > maxItemsToShow;
const canScrollUp = scrollOffset > 0;
const canScrollDown =
scrollOffset + maxItemsToShow < filteredItems.length;
// Determine inline scroll arrow for this item position
let arrowChar = '';
if (
hasScrollArrows &&
index === 0 &&
canScrollUp &&
!isSelected
) {
arrowChar = '▲';
} else if (
hasScrollArrows &&
index === visibleItems.length - 1 &&
canScrollDown &&
!isSelected
) {
arrowChar = '▼';
}
return (
<Box key={item.key} marginBottom={1} marginX={1}>
{renderItem
? renderItem(item, isSelected, maxLabelWidth)
: defaultRenderItem(item, isSelected, maxLabelWidth)}
: defaultRenderItem(
item,
isSelected,
maxLabelWidth,
arrowChar,
)}
</Box>
);
})}
{filteredItems.length > maxItemsToShow && (
<Box marginX={1}>
<Text color={theme.text.secondary}></Text>
</Box>
)}
</>
)}
</Box>
@@ -8,28 +8,22 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
"▲
8. Item 8
"▲ 8. Item 8
9. Item 9
● 10. Item 10
"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
"▲
4. Item 4
"▲ 4. Item 4
5. Item 5
● 6. Item 6
"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
"
● 1. Item 1
"● 1. Item 1
2. Item 2
3. Item 3
3. Item 3
"
`;
@@ -7,10 +7,10 @@ exports[`SearchableList > should match snapshot 1`] = `
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
● Item One
● Item One
Description for item one
Item Two
Item Two
Description for item two
Item Three
@@ -25,10 +25,10 @@ exports[`SearchableList > should reset selection to top when items change if res
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
Item One
Item One
Description for item one
● Item Two
● Item Two
Description for item two
Item Three
@@ -43,7 +43,7 @@ exports[`SearchableList > should reset selection to top when items change if res
│ One │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
● Item One
● Item One
Description for item one
"
`;
@@ -55,10 +55,10 @@ exports[`SearchableList > should reset selection to top when items change if res
│ Search... │
╰────────────────────────────────────────────────────────────────────────────────────────────────╯
● Item One
● Item One
Description for item one
Item Two
Item Two
Description for item two
Item Three