feat(learner): module lists and folds open with motion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a953e35178
commit
a83ac02a90
@@ -59,6 +59,10 @@ const click = (sel: string) => chrome.eval(`document.querySelector(${js(sel)}).c
|
||||
const text = (sel: string) => chrome.eval<string | null>(`document.querySelector(${js(sel)})?.textContent ?? null`);
|
||||
const rect = (sel: string) => chrome.eval<string>(`JSON.stringify(document.querySelector(${js(sel)}).getBoundingClientRect())`);
|
||||
|
||||
/** The animations and transitions running now, as "name@class::pseudo" (some pseudo-elements send no events). */
|
||||
const running = () => chrome.eval<string[]>(`document.getAnimations().map((a) => (a.transitionProperty || a.animationName) + "@" + (a.effect.target.getAttribute?.("class") || a.effect.target.tagName?.toLowerCase()) + (a.effect.pseudoElement || ""))`);
|
||||
const afterFrame = "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))";
|
||||
|
||||
/** Full page load of `path`; waits for `ready` to exist. */
|
||||
async function open(path: string, ready: string) {
|
||||
await chrome.goto(`${base}${path}`, 50);
|
||||
@@ -150,4 +154,39 @@ run("navigation motion (headless Chrome)", () => {
|
||||
await until(async () => (await chrome.eval<number>("scrollY")) === 900, "restored scroll", 2000);
|
||||
expect(await rect(".lr-side")).toBe(side);
|
||||
}, 30_000);
|
||||
|
||||
test("a module opened by the user rises in; lists do not animate on page load or lesson change", async () => {
|
||||
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||
await click('.lr-les[href="/c/sample/m1-code"]');
|
||||
await until(async () => (await text(".lr-main .lr-lesson h1.title")) === "Code along", "code lesson");
|
||||
await Bun.sleep(300);
|
||||
expect((await m()).anims.filter((a) => a.includes("lr-lessons"))).toEqual([]);
|
||||
await reset();
|
||||
const head = '[...document.querySelectorAll(".lr-mod-head")][1]';
|
||||
expect(await chrome.eval<string>(`${head}.getAttribute("aria-expanded")`)).toBe("false");
|
||||
await chrome.eval(`${head}.click(), true`);
|
||||
// Open at once: the list is there and focusable in the same task as the click.
|
||||
expect(await chrome.eval<number>(`${head}.parentElement.querySelectorAll(".lr-lessons a").length`)).toBe(2);
|
||||
await Bun.sleep(300);
|
||||
expect((await m()).anims).toContain("stc-enter@lr-lessons");
|
||||
}, 30_000);
|
||||
|
||||
test("folds open and close by animating their height", async () => {
|
||||
// Chrome animates ::details-content without listing it in getAnimations() or sending events, so sample its height.
|
||||
const sample = (open: boolean) => chrome.eval<number[]>(`(() => {
|
||||
const d = document.querySelector("details.fold.transcript");
|
||||
d.querySelector("summary").click();
|
||||
if (d.open !== ${open}) return [-1];
|
||||
const out = [], t0 = performance.now();
|
||||
return new Promise((r) => { const f = () => { out.push(parseFloat(getComputedStyle(d, "::details-content").height)); if (performance.now() - t0 < 400) requestAnimationFrame(f); else r(out); }; f(); });
|
||||
})()`);
|
||||
await open("/c/sample/m1-video", "details.fold.transcript");
|
||||
const opening = await sample(true);
|
||||
const full = opening.at(-1)!;
|
||||
expect(full).toBeGreaterThan(20);
|
||||
expect(opening.some((h) => h > 1 && h < full - 1)).toBe(true); // passed through in-between heights
|
||||
const closing = await sample(false);
|
||||
expect(closing.some((h) => h > 1 && h < full - 1)).toBe(true);
|
||||
expect(closing.at(-1)).toBe(0);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -81,7 +81,12 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu
|
||||
.map((m) => ({ m, lessons: query ? m.lessons.filter((l) => fold(l.title).includes(query)) : m.lessons }))
|
||||
.filter(({ m, lessons }) => !query || lessons.length || fold(m.title).includes(query)), [course, query]);
|
||||
|
||||
const toggle = (id: string) => setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
// Only a list the user opens rises in; lists open on page load or after a lesson change stay still.
|
||||
const [opened, setOpened] = useState<string | null>(null);
|
||||
const toggle = (id: string) => {
|
||||
if (!openMods.has(id)) setOpened(id);
|
||||
setOpenMods((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
|
||||
};
|
||||
const sourceLine = course.authors.length ? course.authors.join(", ") : course.tracks.length > 1 ? `Tracks: ${course.tracks.map((t) => t.label).join(", ")}` : course.pitch ?? "";
|
||||
const errors = course.diagnostics.filter((d) => d.level === "error");
|
||||
|
||||
@@ -108,15 +113,15 @@ export function Outline({ course, currentLessonId }: { course: CoursePayload; cu
|
||||
</div>
|
||||
) : null}
|
||||
{filtered.map(({ m, lessons }) => (
|
||||
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
|
||||
<ModuleBlock key={m.id} course={course} m={m} lessons={lessons} open={!!query || openMods.has(m.id)} enter={opened === m.id} current={m.id === current?.id} currentLessonId={currentLessonId} onToggle={() => toggle(m.id)} />
|
||||
))}
|
||||
{query && filtered.length === 0 ? <p className="lr-nohit">No lessons match “{q.trim()}”.</p> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onToggle }: {
|
||||
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
|
||||
function ModuleBlock({ course, m, lessons, open, enter, current, currentLessonId, onToggle }: {
|
||||
course: CoursePayload; m: OutlineModule; lessons: OutlineLesson[]; open: boolean; enter: boolean; current: boolean; currentLessonId: string | null; onToggle: () => void;
|
||||
}) {
|
||||
const d = m.lessons.filter((l) => course.progress[l.id]?.state === "done").length;
|
||||
const pct = m.lessons.length ? d / m.lessons.length : 0;
|
||||
@@ -131,7 +136,7 @@ function ModuleBlock({ course, m, lessons, open, current, currentLessonId, onTog
|
||||
</button>
|
||||
{course.signedIn ? <ProgressBar value={pct} dim={pct === 1 || !current} label={`${m.title} progress`} /> : <span style={{ display: "block", height: 4 }} />}
|
||||
{open ? (
|
||||
<ol className="lr-lessons">
|
||||
<ol className="lr-lessons" data-enter={enter ? "" : undefined}>
|
||||
{lessons.map((l) => {
|
||||
const isDone = course.progress[l.id]?.state === "done";
|
||||
const cls = ["lr-les", isDone && "done", m.locked && "locked"].filter(Boolean).join(" ");
|
||||
|
||||
@@ -335,6 +335,14 @@ export const learnerCss = String.raw`
|
||||
New content rises in when it arrives (a page, a lesson, the library); the header, sidebar and dock stay put.
|
||||
With view transitions the old page dissolves over it (tokens.css); without them this is the whole effect. */
|
||||
.lr-main .lr-lesson, .lr-main > article, .stc-main > .lr > article, .lr-lib { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||
/* a module list the user opens */
|
||||
.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||
/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */
|
||||
@supports selector(::details-content) {
|
||||
.lr details.fold { interpolate-size: allow-keywords; }
|
||||
.lr details.fold::details-content { height: 0; opacity: 0; overflow: clip; transition: height var(--t-fold) var(--ease-out), opacity var(--t-fold) var(--ease-out), content-visibility var(--t-fold) allow-discrete; }
|
||||
.lr details.fold[open]::details-content { height: auto; opacity: 1; }
|
||||
}
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
@media (max-width: 1100px) {
|
||||
|
||||
Reference in New Issue
Block a user