Skip to content

Commit 8d30380

Browse files
authored
feat(conversation): add ConversationMap and useReadingPosition (#123)
* 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. * fix(conversation): end unseen map jumps and keep the tick in view A tick clicked mid-glide pinned its turn as current until the turn had been seen. If ConversationContent went away first, or the turn was removed, the pin was never released and a stale tick stayed current. The pin now ends with the view it was gliding, and with its item. Keeping the current tick in view ran only when the current turn changed, so a rail that shrank could clip it. A ResizeObserver on the rail now places it again at once. The tick's data-current attribute is dropped: nothing read it, and aria-current already carries the state. * fix(conversation): give map ticks a 24px target on touch screens The map's ticks shrink so the whole conversation fits on the rail: 14px tall at most, 6px on a long chat. That is fine for a mouse but below the 24px target WCAG 2.5.8 asks for, and hard to hit with a finger. On a coarse pointer each tick now keeps a 24px height and the rail scrolls, which already keeps the current tick in view. A mouse still gets the dense rail. * fix(conversation): end a map jump cut short before its turn is seen A jump pins its turn as current until the reader scrolls or the turn leaves the view. When momentum, a tap or a wheel that moved nothing stopped the glide before the turn came on screen, nothing released the pin, and the map marked a turn that was not on screen as current until the reader next scrolled. The jump now hears from the glide whether it arrived, and drops a pin whose turn was never seen. Document that the map's turns must be mounted: a tick for a turn that is not mounted does not jump, even when onSelect mounts it. * fix(conversation): keep focus on the map when a focused turn goes When the turn of the focused tick left `turns`, as when a history window trims it or the host deletes it, its button unmounted and focus fell to the page body; the next Tab started from the top of the page. A layout effect now notices the focused turn is gone and, if focus is on the body, moves it to the rail's tab stop (the current tick, or the first), or to the conversation's log when the rail itself is gone. * fix(conversation): let Escape close a map preview the mouse opened Escape was handled only by the map's own keydown, so a preview card opened by resting the mouse on a tick stayed over the content while focus was in the composer or the log. Content shown on hover has to be dismissable without moving the pointer or focus. While a preview is open, Escape anywhere in the document now closes it. * refactor(conversation): measure the map preview outside its updater When the rail scrolled, the preview card's position was measured inside a setPreview updater. React runs a queued updater during the next render, so the DOM was read during render, and StrictMode ran the reads twice. The scroll handler now measures the tick of the preview it sees and passes a pure updater that applies the result only if that preview is still open, so a close queued by a wheel in the same frame still wins. * docs(conversation): record which target-size rule the mouse rail uses With a mouse, map ticks are under 24px tall, and the comment said only that this was fine for a mouse. It now names the WCAG 2.5.8 exception the dense rail relies on: scrolling the conversation is an equivalent way to reach every turn a tick jumps to. Touch keeps its 24px ticks. * test(conversation): share the conversation e2e helpers with the map The map spec declared its own container, settled, offsetOf and wheel, copies of the helpers the other conversation specs import. It now imports them from the shared module. * fix(conversation): forward ConversationMap's ref under React 18 ConversationMap took its props as the nav's, ref included, and spread them onto the nav. React 19 passes ref as a prop, but React 18 strips it, so a ref never reached the nav on React 18, the lower end of the peer range. It is now wrapped in forwardRef and the ref set on the nav.
1 parent 361e789 commit 8d30380

9 files changed

Lines changed: 2154 additions & 0 deletions

‎e2e/conversation-map.spec.ts‎

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

‎e2e/fixtures.tsx‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ import {
6464
MessageFooter,
6565
ConversationEarlier,
6666
useHistoryWindow,
67+
ConversationMap,
68+
type ConversationMapTurn,
6769
} from "@corbits/react-ui";
6870
import {
6971
CommandPalette,
@@ -1305,6 +1307,57 @@ function HistoryWindowScenario({
13051307
);
13061308
}
13071309

1310+
const mapTurns = (length: number): ConversationMapTurn[] =>
1311+
Array.from({ length }, (_, index) => ({
1312+
id: `t${index}`,
1313+
label: `Question ${index}`,
1314+
preview: `Answer ${index}`,
1315+
}));
1316+
1317+
const MAP_TURNS = mapTurns(20);
1318+
1319+
/** Turns of fixed height: a 24px question, then a 72px answer. Twenty by
1320+
* default. */
1321+
function ConversationMapScenario({
1322+
turns = MAP_TURNS,
1323+
}: {
1324+
turns?: ConversationMapTurn[];
1325+
}) {
1326+
return (
1327+
<div
1328+
style={{
1329+
display: "flex",
1330+
flexDirection: "column",
1331+
height: 400,
1332+
width: 600,
1333+
}}
1334+
>
1335+
<Conversation>
1336+
<ConversationMap turns={turns} />
1337+
<ConversationContent>
1338+
{turns.flatMap((turn) => [
1339+
<ConversationItem
1340+
key={turn.id}
1341+
itemId={turn.id}
1342+
data-testid={turn.id}
1343+
>
1344+
<p style={{ height: 24, margin: "0 0 0 48px" }}>{turn.label}</p>
1345+
</ConversationItem>,
1346+
<ConversationItem
1347+
key={`${turn.id}a`}
1348+
itemId={`${turn.id}a`}
1349+
data-testid={`${turn.id}a`}
1350+
>
1351+
<p style={{ height: 72, margin: "0 0 0 48px" }}>{turn.preview}</p>
1352+
</ConversationItem>,
1353+
])}
1354+
</ConversationContent>
1355+
<ConversationScrollButton />
1356+
</Conversation>
1357+
</div>
1358+
);
1359+
}
1360+
13081361
export const scenarios: Record<string, () => ReactNode> = {
13091362
smoke: () => <Button>Ready</Button>,
13101363
"approval-card": () => <Approval state="idle" />,
@@ -1366,4 +1419,12 @@ export const scenarios: Record<string, () => ReactNode> = {
13661419
"history-window-reach": () => (
13671420
<HistoryWindowScenario local={200} remote={0} />
13681421
),
1422+
"conversation-map": () => <ConversationMapScenario />,
1423+
// Below the fold of a tall page, with more turns than the rail can fit.
1424+
"conversation-map-tall-page": () => (
1425+
<>
1426+
<div style={{ height: 1600 }} />
1427+
<ConversationMapScenario turns={mapTurns(80)} />
1428+
</>
1429+
),
13691430
};

0 commit comments

Comments
 (0)