feat(learner): progress, toast and menu motion
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d5e836cc79
commit
370d381ff4
4 files changed
+44
-5
No files matched your search
@@ -224,4 +224,29 @@ run("navigation motion (headless Chrome)", () => {
|
|||||||
await until(() => chrome.eval<boolean>(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn");
|
await until(() => chrome.eval<boolean>(`document.querySelector(".stage canvas[data-front]").dataset.page === "3"`), "slide 3 drawn");
|
||||||
expect(await text(".deckbar .count")).toBe("3 / 3");
|
expect(await text(".deckbar .count")).toBe("3 / 3");
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
|
test("progress fills, the toast and the course menu answer with motion; Escape still closes at once", async () => {
|
||||||
|
await open("/c/sample/m1-intro", ".lr-main .lr-lesson h1.title");
|
||||||
|
const status = await chrome.eval<number>(`fetch("/api/auth/sign-up/email", { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: "motion@example.org", name: "Motion", password: "correct horse battery" }) }).then((r) => r.status)`);
|
||||||
|
expect(status).toBe(200);
|
||||||
|
await open("/c/sample/m1-intro", ".stc-dock button[aria-pressed]:not([disabled])");
|
||||||
|
await click(".stc-dock button[aria-pressed]");
|
||||||
|
await until(async () => (await m()).trans.includes("width@i"), "progress fill transition");
|
||||||
|
|
||||||
|
// The toast rises in, then fades out before it is removed.
|
||||||
|
await open("/c/sample/m2-ex-add", ".stc-seg button");
|
||||||
|
await chrome.eval(`[...document.querySelectorAll(".stc-seg button")].find((b) => b.textContent === "Reading").click(), true`);
|
||||||
|
await until(async () => (await m()).anims.includes("stc-enter@lr lr-toast"), "toast enter");
|
||||||
|
await until(async () => (await m()).anims.includes("stc-fade-out@lr lr-toast is-leaving"), "toast leave", 5000);
|
||||||
|
await until(async () => !(await exists(".lr-toast")), "toast removed", 2000);
|
||||||
|
|
||||||
|
// The course menu drops in; Escape closes it in the same task.
|
||||||
|
await reset();
|
||||||
|
await click(".lr-switch-btn");
|
||||||
|
await until(async () => (await m()).anims.includes("stc-drop@lr-menu"), "menu enter");
|
||||||
|
await chrome.send("Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
||||||
|
await chrome.send("Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
|
||||||
|
expect(await exists(".lr-menu")).toBe(false);
|
||||||
|
}, 40_000);
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import type { AuthUser } from "@stc/contracts";
|
import type { AuthUser } from "@stc/contracts";
|
||||||
|
import { reducedMotion } from "@stc/ui";
|
||||||
import type { CoursePayload, LibraryPayload } from "../shared/types";
|
import type { CoursePayload, LibraryPayload } from "../shared/types";
|
||||||
import { learnerCss } from "./styles";
|
import { learnerCss } from "./styles";
|
||||||
|
|
||||||
@@ -154,14 +155,22 @@ export function clearCourseCache(): void { courseCache.clear(); courseVersion.se
|
|||||||
|
|
||||||
/* ---------- toast ---------- */
|
/* ---------- toast ---------- */
|
||||||
|
|
||||||
const toastStore = store<string | null>(null);
|
export interface ToastState { text: string; leaving: boolean }
|
||||||
|
const toastStore = store<ToastState | null>(null);
|
||||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
/** How long the toast takes to fade out (--t-exit). */
|
||||||
|
const TOAST_EXIT_MS = 120;
|
||||||
export function toast(msg: string): void {
|
export function toast(msg: string): void {
|
||||||
toastStore.set(msg);
|
toastStore.set({ text: msg, leaving: false });
|
||||||
clearTimeout(toastTimer);
|
clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => toastStore.set(null), 2600);
|
toastTimer = setTimeout(() => {
|
||||||
|
const t = toastStore.get();
|
||||||
|
if (!t || reducedMotion()) { toastStore.set(null); return; }
|
||||||
|
toastStore.set({ ...t, leaving: true });
|
||||||
|
toastTimer = setTimeout(() => toastStore.set(null), TOAST_EXIT_MS);
|
||||||
|
}, 2600);
|
||||||
}
|
}
|
||||||
export function useToast(): string | null { return useStore(toastStore); }
|
export function useToast(): ToastState | null { return useStore(toastStore); }
|
||||||
|
|
||||||
/* ---------- formatting ---------- */
|
/* ---------- formatting ---------- */
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function HeaderExtras({ course, lookMode, outline }: {
|
|||||||
</div>,
|
</div>,
|
||||||
host,
|
host,
|
||||||
)}
|
)}
|
||||||
{msg ? <div className="lr lr-toast" role="status">{msg}</div> : null}
|
{msg ? <div className={msg.leaving ? "lr lr-toast is-leaving" : "lr lr-toast"} role="status">{msg.text}</div> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,6 +342,11 @@ export const learnerCss = String.raw`
|
|||||||
.lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; }
|
.lr .stage canvas[data-front][data-dir="next"] { animation: stc-slide-next var(--t-enter) var(--ease-out) backwards; }
|
||||||
.lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; }
|
.lr .stage canvas[data-front][data-dir="prev"] { animation: stc-slide-prev var(--t-enter) var(--ease-out) backwards; }
|
||||||
.lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; }
|
.lr .stage canvas[data-leaving] { animation: stc-hold-out var(--t-enter) linear both; }
|
||||||
|
/* overlays answer the action that opened them; closing is instant except for the toast's short fade */
|
||||||
|
.lr-menu { animation: stc-drop var(--t-fast) var(--ease-out) backwards; }
|
||||||
|
.lr-shell.side-open .lr-scrim { animation: stc-fade-in var(--t-fast) var(--ease-out) backwards; }
|
||||||
|
.lr-toast { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
||||||
|
.lr-toast.is-leaving { animation: stc-fade-out var(--t-exit) var(--ease-out) both; }
|
||||||
/* a module list the user opens */
|
/* a module list the user opens */
|
||||||
.lr-lessons[data-enter] { animation: stc-enter var(--t-enter) var(--ease-out) backwards; }
|
.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 */
|
/* folds and hint tiers grow to their height where the browser can animate it, and open instantly elsewhere */
|
||||||
|
|||||||
Reference in new issue
Block a user