Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions src/components/StoryList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,14 @@ export function scrollToStory(
state: StoryListState,
index: number,
): void {
const itemHeight = 2; // Each story item is ~2 lines
const gap = 1; // Gap between items
const itemTop = index * (itemHeight + gap);
const itemBottom = itemTop + itemHeight;
const viewportHeight = state.scroll.height;
const item = state.items.get(index);
if (!item) return;

// Get the item's position relative to the scroll content
const contentY = state.scroll.content.y;
const itemTop = item.y - contentY;
const itemBottom = itemTop + item.height;
const viewportHeight = state.scroll.viewport.height;
const currentScroll = state.scroll.scrollTop;

// Only scroll if the item is outside the visible viewport
Expand Down
81 changes: 81 additions & 0 deletions src/test/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,87 @@ describe("HackerNewsApp", () => {
});
});

describe("Story List Scroll", () => {
it("should scroll down to show off-screen selected story", async () => {
// Create more stories than can fit in viewport
const posts = createMockPosts(20);
ctx.app.setPostsForTesting(posts);
await ctx.renderOnce();

const storyListState = (ctx.app as any).storyListState;
expect(storyListState.scroll.scrollTop).toBe(0);

// Navigate down past the visible viewport (each story is ~3 lines with gap)
for (let i = 0; i < 15; i++) {
ctx.mockInput.pressKey("j");
await ctx.renderer.idle();
await ctx.renderOnce();
}
// Extra render to ensure scroll position is fully applied
await ctx.renderer.idle();
await ctx.renderOnce();

// Scroll should have changed to show the selected story
expect(ctx.app.currentSelectedIndex).toBe(14);
expect(storyListState.scroll.scrollTop).toBeGreaterThan(0);
});

it("should scroll up to show off-screen selected story", async () => {
// Create more stories than can fit in viewport
const posts = createMockPosts(20);
ctx.app.setPostsForTesting(posts);
await ctx.renderOnce();

const storyListState = (ctx.app as any).storyListState;

// Navigate down to bottom
for (let i = 0; i < 19; i++) {
ctx.mockInput.pressKey("j");
await ctx.renderer.idle();
await ctx.renderOnce();
}
await ctx.renderer.idle();
await ctx.renderOnce();
expect(ctx.app.currentSelectedIndex).toBe(18);
const scrollAtBottom = storyListState.scroll.scrollTop;
expect(scrollAtBottom).toBeGreaterThan(0);

// Navigate back up past visible viewport
for (let i = 0; i < 15; i++) {
ctx.mockInput.pressKey("k");
await ctx.renderer.idle();
await ctx.renderOnce();
}
await ctx.renderer.idle();
await ctx.renderOnce();

// Scroll should have decreased to show the selected story
expect(ctx.app.currentSelectedIndex).toBe(3);
expect(storyListState.scroll.scrollTop).toBeLessThan(scrollAtBottom);
});

it("should not scroll when selected story is already visible", async () => {
const posts = createMockPosts(20);
ctx.app.setPostsForTesting(posts);
await ctx.renderOnce();

const storyListState = (ctx.app as any).storyListState;

// Select first story
ctx.mockInput.pressKey("j");
await ctx.renderer.idle();
await ctx.renderOnce();
expect(storyListState.scroll.scrollTop).toBe(0);

// Navigate to second story (should still be visible without scrolling)
ctx.mockInput.pressKey("j");
await ctx.renderer.idle();
await ctx.renderOnce();
expect(ctx.app.currentSelectedIndex).toBe(1);
expect(storyListState.scroll.scrollTop).toBe(0);
});
});

describe("Comment Navigation", () => {
beforeEach(async () => {
const mockPost = createMockPostWithComments({}, 5);
Expand Down