Skip to content

Commit ea5bea3

Browse files
committed
feat(conversation): add ConversationMap and useReadingPosition
ConversationMap shows one tick per turn, marked faint, on screen or current, previews a turn on hover or focus and jumps to it with the conversation's glide. The rail is one tab stop with arrow keys and Enter, and keeping the current tick in view scrolls only the rail. useReadingPosition reports the current and on-screen turns from one IntersectionObserver over the item registry, measuring on the conversation's per-frame scroll snapshots. A jumped-to turn stays current until the reader scrolls.
1 parent 91acbf0 commit ea5bea3

9 files changed

Lines changed: 1755 additions & 0 deletions

‎e2e/conversation-map.spec.ts‎

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
const container = (page: Page) =>
4+
page.locator("[data-slot='conversation-content']");
5+
6+
const tick = (page: Page, name: string) =>
7+
page
8+
.getByRole("navigation", { name: "Conversation map" })
9+
.getByRole("button", { name, exact: true });
10+
11+
const current = (page: Page) =>
12+
page.locator("[data-slot='conversation-map-tick'][aria-current='location']");
13+
14+
const scrollTop = (page: Page) =>
15+
container(page).evaluate((element) => element.scrollTop);
16+
17+
/** Waits for the view to stop moving (a glide or a wheel scroll to land). */
18+
const settled = async (page: Page) => {
19+
let previous = Number.NaN;
20+
await expect
21+
.poll(
22+
async () => {
23+
const now = await scrollTop(page);
24+
const still = now === previous;
25+
previous = now;
26+
return still;
27+
},
28+
{ intervals: [100] },
29+
)
30+
.toBe(true);
31+
};
32+
33+
/** How far an item's top sits below the top of the view. */
34+
const offsetOf = (page: Page, id: string) =>
35+
container(page).evaluate((element, itemId) => {
36+
const item = element.querySelector(`[data-testid='${itemId}']`);
37+
if (item === null) throw new Error(`no item ${itemId}`);
38+
return (
39+
item.getBoundingClientRect().top - element.getBoundingClientRect().top
40+
);
41+
}, id);
42+
43+
/** The turn whose question last crossed a line a third of the way down. */
44+
const turnOnReadingLine = (page: Page) =>
45+
container(page).evaluate((element) => {
46+
const view = element.getBoundingClientRect();
47+
const line = view.top + view.height / 3;
48+
let reading = "";
49+
for (let index = 0; index < 20; index += 1) {
50+
const start = element.querySelector(`[data-testid='t${index}']`);
51+
if (start !== null && start.getBoundingClientRect().top <= line) {
52+
reading = `Question ${index}`;
53+
}
54+
}
55+
return reading;
56+
});
57+
58+
const wheel = async (page: Page, deltaY: number) => {
59+
const box = await container(page).boundingBox();
60+
if (box === null) throw new Error("expected the conversation on screen");
61+
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
62+
await page.mouse.wheel(0, deltaY);
63+
await settled(page);
64+
};
65+
66+
const open = async (page: Page) => {
67+
await page.goto("/conversation-map");
68+
await expect(page.getByTestId("t19a")).toBeAttached();
69+
await expect
70+
.poll(() =>
71+
container(page).evaluate(
72+
(element) =>
73+
element.scrollHeight - element.scrollTop - element.clientHeight,
74+
),
75+
)
76+
.toBeLessThanOrEqual(1);
77+
};
78+
79+
test.describe("conversation map", () => {
80+
test("shows one tick per turn, the last turn current at the end", async ({
81+
page,
82+
}) => {
83+
await open(page);
84+
await expect(
85+
page.locator("[data-slot='conversation-map-tick']"),
86+
).toHaveCount(20);
87+
await expect(current(page)).toHaveAccessibleName("Question 19");
88+
await expect(tick(page, "Question 18")).toHaveAttribute("data-visible", "");
89+
await expect(tick(page, "Question 0")).not.toHaveAttribute("data-visible");
90+
});
91+
92+
test("pointing at a tick previews its turn", async ({ page }) => {
93+
await open(page);
94+
await tick(page, "Question 3").hover();
95+
const card = page.getByRole("tooltip");
96+
await expect(card).toContainText("Question 3");
97+
await expect(card).toContainText("Answer 3");
98+
await expect(tick(page, "Question 3")).toHaveAccessibleDescription(
99+
"Answer 3",
100+
);
101+
await page.mouse.move(400, 380);
102+
await expect(card).toBeHidden();
103+
});
104+
105+
test("clicking a tick brings its turn to the top of the view", async ({
106+
page,
107+
}) => {
108+
await open(page);
109+
await tick(page, "Question 5").click();
110+
await settled(page);
111+
expect(Math.abs(await offsetOf(page, "t5"))).toBeLessThanOrEqual(1);
112+
await expect(current(page)).toHaveAccessibleName("Question 5");
113+
});
114+
115+
test("the keyboard reaches the current tick; arrows and Enter jump", async ({
116+
page,
117+
}) => {
118+
await open(page);
119+
// The rail comes just before the log in the tab order.
120+
await container(page).focus();
121+
await page.keyboard.press("Shift+Tab");
122+
await expect(tick(page, "Question 19")).toBeFocused();
123+
for (let step = 0; step < 9; step += 1) {
124+
await page.keyboard.press("ArrowUp");
125+
}
126+
await expect(tick(page, "Question 10")).toBeFocused();
127+
await page.keyboard.press("Enter");
128+
await settled(page);
129+
expect(Math.abs(await offsetOf(page, "t10"))).toBeLessThanOrEqual(1);
130+
await expect(current(page)).toHaveAccessibleName("Question 10");
131+
await expect(tick(page, "Question 10")).toBeFocused();
132+
});
133+
134+
test("the current tick follows scrolling", async ({ page }) => {
135+
await open(page);
136+
await wheel(page, -700);
137+
const first = await turnOnReadingLine(page);
138+
expect(first).not.toBe("Question 19");
139+
await expect(current(page)).toHaveAccessibleName(first);
140+
141+
await wheel(page, -500);
142+
const second = await turnOnReadingLine(page);
143+
expect(second).not.toBe(first);
144+
await expect(current(page)).toHaveAccessibleName(second);
145+
});
146+
147+
test("keeping the current tick in view scrolls only the rail", async ({
148+
page,
149+
}) => {
150+
await page.goto("/conversation-map-tall-page");
151+
await expect(page.getByTestId("t79a")).toBeAttached();
152+
await expect(current(page)).toHaveAccessibleName("Question 79");
153+
const rail = page.locator("[data-slot='conversation-map-rail']");
154+
// The rail overflows, and has scrolled to bring the last tick into view.
155+
await expect
156+
.poll(() =>
157+
rail.evaluate((element) => {
158+
const tick = element.querySelector("[aria-current='location']");
159+
if (tick === null) return false;
160+
const view = element.getBoundingClientRect();
161+
const box = tick.getBoundingClientRect();
162+
return (
163+
element.scrollHeight > element.clientHeight &&
164+
element.scrollTop > 0 &&
165+
box.top >= view.top - 1 &&
166+
box.bottom <= view.bottom + 1
167+
);
168+
}),
169+
)
170+
.toBe(true);
171+
// Give any late scroll a few frames to land.
172+
await page.evaluate(
173+
() =>
174+
new Promise<void>((resolve) => {
175+
let frames = 10;
176+
const step = () => {
177+
frames -= 1;
178+
if (frames === 0) resolve();
179+
else requestAnimationFrame(step);
180+
};
181+
requestAnimationFrame(step);
182+
}),
183+
);
184+
expect(await page.evaluate(() => window.scrollY)).toBe(0);
185+
});
186+
187+
test("moving along an overflowing rail keeps the preview open", async ({
188+
page,
189+
}) => {
190+
await page.goto("/conversation-map-tall-page");
191+
await expect(current(page)).toHaveAccessibleName("Question 79");
192+
const card = page.getByRole("tooltip");
193+
/** The focused tick, its preview showing and describing it. */
194+
const previewed = async (index: number) => {
195+
const focused = tick(page, `Question ${index}`);
196+
await expect(focused).toBeFocused();
197+
await expect(card).toBeVisible();
198+
await expect(card).toContainText(`Question ${index}`);
199+
await expect(focused).toHaveAccessibleDescription(`Answer ${index}`);
200+
};
201+
202+
await tick(page, "Question 79").focus();
203+
await previewed(79);
204+
await page.keyboard.press("Home");
205+
await previewed(0);
206+
await page.keyboard.press("End");
207+
await previewed(79);
208+
for (let index = 78; index >= 0; index -= 1) {
209+
await page.keyboard.press("ArrowUp");
210+
await previewed(index);
211+
}
212+
});
213+
214+
test("clicking inside a jumped-to turn keeps it current", async ({
215+
page,
216+
}) => {
217+
await open(page);
218+
await wheel(page, -900);
219+
await tick(page, "Question 18").click();
220+
await settled(page);
221+
await expect(current(page)).toHaveAccessibleName("Question 18");
222+
const before = await scrollTop(page);
223+
224+
await page.getByTestId("t18").getByText("Question 18").click();
225+
await settled(page);
226+
expect(await scrollTop(page)).toBe(before);
227+
await expect(current(page)).toHaveAccessibleName("Question 18");
228+
});
229+
230+
test("a turn the view cannot bring to the top stays current", async ({
231+
page,
232+
}) => {
233+
await open(page);
234+
await wheel(page, -900);
235+
await tick(page, "Question 18").click();
236+
await settled(page);
237+
// The end clamps the glide: the turn is on screen, below the top.
238+
expect(await offsetOf(page, "t18")).toBeGreaterThan(1);
239+
await expect(current(page)).toHaveAccessibleName("Question 18");
240+
241+
// The reader moving on releases it to the reading position.
242+
await wheel(page, -300);
243+
await expect(current(page)).toHaveAccessibleName(
244+
await turnOnReadingLine(page),
245+
);
246+
});
247+
});

‎e2e/fixtures.tsx‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ import {
6060
MessageFooter,
6161
ConversationEarlier,
6262
useHistoryWindow,
63+
ConversationMap,
64+
type ConversationMapTurn,
6365
} from "@corbits/react-ui";
6466
import {
6567
CommandPalette,
@@ -1051,6 +1053,57 @@ function HistoryWindowScenario({
10511053
);
10521054
}
10531055

1056+
const mapTurns = (length: number): ConversationMapTurn[] =>
1057+
Array.from({ length }, (_, index) => ({
1058+
id: `t${index}`,
1059+
label: `Question ${index}`,
1060+
preview: `Answer ${index}`,
1061+
}));
1062+
1063+
const MAP_TURNS = mapTurns(20);
1064+
1065+
/** Turns of fixed height: a 24px question, then a 72px answer. Twenty by
1066+
* default. */
1067+
function ConversationMapScenario({
1068+
turns = MAP_TURNS,
1069+
}: {
1070+
turns?: ConversationMapTurn[];
1071+
}) {
1072+
return (
1073+
<div
1074+
style={{
1075+
display: "flex",
1076+
flexDirection: "column",
1077+
height: 400,
1078+
width: 600,
1079+
}}
1080+
>
1081+
<Conversation>
1082+
<ConversationMap turns={turns} />
1083+
<ConversationContent>
1084+
{turns.flatMap((turn) => [
1085+
<ConversationItem
1086+
key={turn.id}
1087+
itemId={turn.id}
1088+
data-testid={turn.id}
1089+
>
1090+
<p style={{ height: 24, margin: "0 0 0 48px" }}>{turn.label}</p>
1091+
</ConversationItem>,
1092+
<ConversationItem
1093+
key={`${turn.id}a`}
1094+
itemId={`${turn.id}a`}
1095+
data-testid={`${turn.id}a`}
1096+
>
1097+
<p style={{ height: 72, margin: "0 0 0 48px" }}>{turn.preview}</p>
1098+
</ConversationItem>,
1099+
])}
1100+
</ConversationContent>
1101+
<ConversationScrollButton />
1102+
</Conversation>
1103+
</div>
1104+
);
1105+
}
1106+
10541107
export const scenarios: Record<string, () => ReactNode> = {
10551108
smoke: () => <Button>Ready</Button>,
10561109
"approval-card": () => <Approval state="idle" />,
@@ -1106,4 +1159,12 @@ export const scenarios: Record<string, () => ReactNode> = {
11061159
"history-window-reach": () => (
11071160
<HistoryWindowScenario local={200} remote={0} />
11081161
),
1162+
"conversation-map": () => <ConversationMapScenario />,
1163+
// Below the fold of a tall page, with more turns than the rail can fit.
1164+
"conversation-map-tall-page": () => (
1165+
<>
1166+
<div style={{ height: 1600 }} />
1167+
<ConversationMapScenario turns={mapTurns(80)} />
1168+
</>
1169+
),
11091170
};

0 commit comments

Comments
 (0)