diff --git a/packages/app/component-tests/session-question-dock.spec.ts b/packages/app/component-tests/session-question-dock.spec.ts index 0cc12b5ac82..af37dc4830c 100644 --- a/packages/app/component-tests/session-question-dock.spec.ts +++ b/packages/app/component-tests/session-question-dock.spec.ts @@ -836,13 +836,16 @@ story("switches questions, folds and submits on the same frame with the dock's m return frames }, label) + // The last question's answers may be cut to the dock's height, so they come back to the height they rest at. + const resting = await question + .locator('[data-slot="question-options"]') + .evaluate((el) => Math.round(el.getBoundingClientRect().height)) + const folded = await fold("Minimize question") expect(folded.every((frame) => frame.height === 0 && frame.hidden && frame.animations === 0)).toBe(true) const restored = await fold("Restore question") expect( - restored.every( - (frame) => frame.height > 0 && frame.height === frame.list && !frame.hidden && frame.animations === 0, - ), + restored.every((frame) => frame.height > 0 && frame.height === resting && !frame.hidden && frame.animations === 0), ).toBe(true) // Submitting swaps the dock for the composer on one frame, with no closing flight. @@ -1066,3 +1069,208 @@ story("toggles a lone many-answer question and sends it with Enter", async ({ mo await page.keyboard.press("Enter") await expect(question).toBeHidden() }) + +const longList = async (mount: (id: string) => Promise) => { + const component = await mount("session-question-switch--long-list") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("checkbox").first()).toBeFocused() + // The answers box has opened to its first height once it stops changing. + const box = () => question.evaluate((root) => Math.round(root.getBoundingClientRect().height)) + await expect.poll(async () => (await box()) === (await box())).toBe(true) + + return question +} + +const fit = (question: Locator) => + question.evaluate((root) => { + const options = root.querySelector('[data-slot="question-options"]')! + + const timeline = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') + const box = root.getBoundingClientRect() + + return { + height: box.height, + // The whole dock, footer included, sits between the top of the timeline and the foot of the window. + within: box.top >= (timeline?.getBoundingClientRect().top ?? 0) - 0.5 && box.bottom <= innerHeight + 0.5, + scrolls: options.scrollHeight > options.clientHeight + 1, + top: options.scrollTop, + } + }) + +// Whether the whole of an answer's row shows, clear of the feathered edges. +const clear = (row: Locator) => + row.evaluate((input) => { + const row = input.closest('[data-slot="question-option"]')! + const box = input.closest('[data-slot="question-options"]')!.getBoundingClientRect() + const at = row.getBoundingClientRect() + + return at.top >= box.top + 20 - 0.5 && at.bottom <= box.bottom - 20 + 0.5 + }) + +const feathered = (question: Locator) => + question.locator('[data-slot="question-options"]').evaluate((options) => { + const mask = options.style.maskImage + + // A gradient down the list may be written without its direction, which is the default. + return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") } + }) + +story("caps a long list of answers to the panel and scrolls them inside it", async ({ mount }) => { + const question = await longList(mount) + const rest = await fit(question) + expect(rest.within).toBe(true) + expect(rest.scrolls).toBe(true) + await expect(question.getByRole("checkbox", { name: /Footer/ })).not.toBeInViewport() +}) + +story("scrolls the highlighted answer into view with the keys", async ({ mount, page }) => { + const question = await longList(mount) + const footer = question.getByRole("checkbox", { name: /Footer/ }) + + await page.keyboard.press("End") + await page.keyboard.press("ArrowUp") + await expect(footer).toBeFocused() + expect(await clear(footer)).toBe(true) + + await page.keyboard.press("Home") + await expect(question.getByRole("checkbox").first()).toBeFocused() + expect((await fit(question)).top).toBe(0) +}) + +story("feathers only the edges of the answers with more past them", async ({ mount }) => { + const question = await longList(mount) + + // Scrolls to a share of the way down the list. + const edges = (share: number) => + question.evaluate(async (root, share) => { + const options = root.querySelector('[data-slot="question-options"]')! + options.scrollTop = Math.round((options.scrollHeight - options.clientHeight) * share) + await new Promise((done) => options.addEventListener("scroll", done, { once: true })) + const mask = options.style.maskImage + + // A gradient down the list may be written without its direction, which is the default. + return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") } + }, share) + + expect(await edges(0.5)).toEqual({ top: true, bottom: true }) + expect(await edges(1)).toEqual({ top: true, bottom: false }) + expect(await edges(0)).toEqual({ top: false, bottom: true }) +}) + +story("leaves a list that fits without feathered edges", async ({ mount }) => { + const component = await mount("session-question-switch--question-series") + const question = component.locator('[data-component="dock-prompt"][data-kind="question"]') + await expect(question.getByRole("radio").first()).toBeFocused() + await expect + .poll(() => question.locator('[data-slot="question-options"]').evaluate((el) => el.style.maskImage)) + .toBe("") +}) + +story( + "caps a long list to the panel in the app's layout, where the dock overflows a zero-height row", + async ({ mount }) => { + const question = await longList(mount) + // As in the session screen, the composer region moves into a zero-height row at the foot of the panel and overflows + // it upward, over the timeline. + await question.evaluate((root) => { + const dock = root.closest('[data-component="session-composer-dock"]')! + const row = document.createElement("div") + row.style.cssText = + "position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 0" + row.style.flexShrink = "0" + dock.replaceWith(row) + row.append(dock) + dispatchEvent(new Event("resize")) + }) + + // Measured against the empty row, the cap would fall to its 240px minimum. + await expect.poll(async () => (await fit(question)).height).toBeGreaterThan(400) + const rest = await fit(question) + expect(rest.within).toBe(true) + expect(rest.scrolls).toBe(true) + }, +) + +story("brings the picked answer back into view after switching questions", async ({ mount, page }) => { + const question = await longList(mount) + const footer = question.getByRole("checkbox", { name: /Footer/ }) + const title = question.locator('[data-slot="question-header-title"]') + + await page.keyboard.press("End") + await page.keyboard.press("ArrowUp") + await page.keyboard.press(" ") + await expect(footer).toBeChecked() + + await page.keyboard.press("ArrowRight") + await expect(title).toHaveText("2 of 2 questions") + await page.keyboard.press("ArrowLeft") + await expect(title).toHaveText("1 of 2 questions") + await expect(footer).toBeFocused() + expect(await clear(footer)).toBe(true) +}) + +story("sizes the answers box to each question with the dock's motion off", async ({ mount, page }) => { + const question = await longList(mount) + const options = question.locator('[data-slot="question-options"]') + const rest = await fit(question) + expect(rest.scrolls).toBe(true) + expect(await feathered(question)).toEqual({ top: false, bottom: true }) + + await page.keyboard.press("ArrowRight") + await expect(question.getByRole("radio").first()).toBeFocused() + await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: false }) + expect(await options.evaluate((el) => el.style.height)).toBe("") + expect((await fit(question)).scrolls).toBe(false) + + await page.keyboard.press("ArrowLeft") + await expect(question.getByRole("checkbox").first()).toBeFocused() + await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: true }) + expect((await fit(question)).height).toBeCloseTo(rest.height, 0) +}) + +story.describe("a long list with the question dock's motion on", () => { + story.beforeEach(({ page }) => + page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))), + ) + + story("restores a long list without growing past where it rests", async ({ mount }) => { + const question = await longList(mount) + const rest = await fit(question) + await question.getByRole("button", { name: "Minimize question" }).click() + await expect(question.getByRole("button", { name: "Restore question" })).toBeVisible() + await expect + .poll(() => + question.locator('[data-slot="question-options"]').evaluate((el) => el.getBoundingClientRect().height), + ) + .toBe(0) + + const frames = await question.evaluate(async (root) => { + const options = root.querySelector('[data-slot="question-options"]')! + root.querySelector('[data-slot="question-header-actions"] > button:last-of-type')!.click() + + return Array.from({ length: 45 }).reduce>( + async (list) => { + const frames = await list + await new Promise((done) => requestAnimationFrame(done)) + + return [ + ...frames, + { + height: root.getBoundingClientRect().height, + asked: parseFloat(options.style.height) || 0, + given: options.getBoundingClientRect().height, + }, + ] + }, + Promise.resolve([]), + ) + }) + + const heights = frames.map((frame) => frame.height) + expect(heights.every((height, i) => i === 0 || height >= heights[i - 1]! - 0.5)).toBe(true) + expect(Math.max(...heights)).toBeLessThanOrEqual(rest.height + 0.5) + expect(heights.at(-1)).toBeCloseTo(rest.height, 0) + // The answers box is never asked to open past the room it is given, which is what made it snap back at the end. + expect(frames.every((frame) => frame.asked <= frame.given + 0.5)).toBe(true) + }) +}) diff --git a/packages/app/src/session/question-switch.stories.tsx b/packages/app/src/session/question-switch.stories.tsx index 1d186330777..038ca1a8437 100644 --- a/packages/app/src/session/question-switch.stories.tsx +++ b/packages/app/src/session/question-switch.stories.tsx @@ -89,3 +89,68 @@ export const QuestionSeries = { /> ), } + +// A pick-any question with more answers than the dock has room for, then a short one. +export const LongList = { + render: () => ( + + ), +} diff --git a/packages/app/src/session/requests/session-question-dock.tsx b/packages/app/src/session/requests/session-question-dock.tsx index c606be68817..7cf39ba70ea 100644 --- a/packages/app/src/session/requests/session-question-dock.tsx +++ b/packages/app/src/session/requests/session-question-dock.tsx @@ -153,6 +153,9 @@ const footer = { const minimizing: DurationOptions = { duration: 0.3, bounce: 0 } +// How far a list that scrolls fades out at an edge with more answers past it. +const feather = 24 + // Where a copy of the question sits on screen relative to its place at rest, how much of it shows, and how close it is // to sharp and full size. type Pose = { x: number; shown: number; settle: number } @@ -163,8 +166,9 @@ const rest: Pose = { x: 0, shown: 1, settle: 1 } // to 1 along the way. type Track = { from: Pose; to: number; spring: Spring } -// The question being left: inert copies of its text and its answers, and how tall they are. -type Ghost = Track & { tab: number; els: HTMLElement[]; details: number; list: number } +// The question being left: inert copies of its text and its answers, how tall they are, and how far its answers were +// scrolled. +type Ghost = Track & { tab: number; els: HTMLElement[]; details: number; list: number; scroll: number } type QuestionField = FormStringField | FormMultiselectField @@ -343,30 +347,53 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => setStore("answers", store.tab, next ? [next] : []) } + // The dock's heights from its last measure: at most, and of everything but the question text and the answers box. + // The answers box takes what the question text leaves, and opens only to the room it will have at rest, so it never + // grows past it and springs back. + const limit = { max: Infinity, chrome: 0 } + const cap = () => limit.max - limit.chrome + // The room the answers box has now, while the question text springs to its height, and once it is there. + const room = () => cap() - boxes.details.value + const settled = (details = content.details) => cap() - details + const scroll = { top: 0, view: 0 } + const measure = () => { if (!root) return const scroller = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport') const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined + // The timeline starts under its sticky header, or at its own top when it has none. const top = - head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0 - - if (!top) { - root.style.removeProperty("--question-prompt-max-height") - - return - } + head instanceof HTMLElement && head.classList.contains("sticky") + ? head.getBoundingClientRect().bottom + : scroller instanceof HTMLElement + ? scroller.getBoundingClientRect().top + : 0 const dock = root.closest('[data-component="session-composer-dock"]') + // The dock sits at the foot of the column it shares with the timeline, in the app inside a zero-height row that it + // overflows upward. Grown past the column it spills out of it, so the column bounds it, not the dock's own edge. + const column = bounds(dock?.parentElement) + const bottom = Math.min(window.innerHeight, column?.bottom ?? window.innerHeight) - if (!(dock instanceof HTMLElement)) return + // Whatever the dock holds besides the question, above it or below, takes from the same height. + const around = + dock instanceof HTMLElement + ? Math.max(0, dock.getBoundingClientRect().height - root.getBoundingClientRect().height) + : 0 - const dockBottom = dock.getBoundingClientRect().bottom - const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom) const gap = 8 - const max = Math.max(240, Math.floor(dockBottom - top - gap - below)) - root.style.setProperty("--question-prompt-max-height", `${max}px`) + const height = bottom - Math.max(0, top, column?.top ?? 0) - around + limit.max = Math.max(240, Math.floor(height - gap)) + + // Minimized, the answers box is gone, so only an open dock is measured. + if (!store.minimized && optionsRef && detailsFrame) + limit.chrome = root.offsetHeight - optionsRef.offsetHeight - detailsFrame.offsetHeight + root.style.setProperty("--question-prompt-max-height", `${limit.max}px`) + + if (content.list > 0) boxes.list.snap(Math.min(content.list, settled())) + paint() } const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i)) @@ -389,7 +416,28 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => if (store.editing) return const el = next === options().length ? customRef : optsRef[next] - el?.focus(how) + el?.focus({ preventScroll: true }) + + if (!how?.preventScroll) reveal(rowAt(next)) + } + + const rowAt = (i: number) => (i === options().length ? customRow : optsRef[i]) + + // A list that scrolls follows the highlighted answer at once, keeping it clear of the feathered edges. + const reveal = (row: HTMLElement | undefined, view = optionsRef?.clientHeight ?? 0) => { + if (!row || !optionsRef || !listRef) return + const top = row.offsetTop + listRef.offsetTop + const edge = Math.min(feather, view / 4) + optionsRef.scrollTop = Math.min(top - edge, Math.max(optionsRef.scrollTop, top + row.offsetHeight + edge - view)) + scroll.top = optionsRef.scrollTop + pin() + } + + // The leaving copy of the answers stays where they were on screen however far the live list scrolls under it. + const pin = () => { + const answers = ghost?.els[1] + + if (ghost && answers) answers.style.top = `${scroll.top - ghost.scroll}px` } // Measuring reads layout, so it waits while the dock's own springs are moving and runs once they come to rest. @@ -404,7 +452,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => } onMount(() => { - requestMeasure() + measure() makeEventListener(window, "resize", requestMeasure) @@ -426,19 +474,28 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => if (!list || !details) return const observer = new ResizeObserver((entries) => { + const fresh = { details: content.details === 0, list: content.list === 0 } + entries.forEach((entry) => { const size = entry.borderBoxSize[0]?.blockSize if (size === undefined) return const key = entry.target === list ? "list" : "details" - const spring = boxes[key] - - if (content[key] === 0 || !questionMotion()) spring.snap(size) - else tune(spring, resize()).set(size) content[key] = size if (key === "list") setStore("listHeight", size) }) + + // The answers box opens only as far as the room the new question text leaves it at rest. + ;(["details", "list"] as const).forEach((key) => { + const spring = boxes[key] + const size = key === "list" ? Math.min(content.list, settled()) : content.details + + if (spring.target === size) return + + if (fresh[key] || !questionMotion()) spring.snap(size) + else tune(spring, resize()).set(size) + }) wake() }) @@ -451,7 +508,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => createEffect(() => { const el = highlightRef const index = store.focus - const row = index === options().length ? customRow : optsRef[index] + const row = rowAt(index) void store.listHeight if (!el || !row || !listRef) return @@ -755,9 +812,10 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => ...track(live, -dir * reach, moving, options, v.launch()), tab: store.tab, // The answers box scrolls back to the top for the new question, so the copy keeps its place on screen. - els: [copy(detailsRef, detailsFrame), copy(listRef, optionsTrack, -(optionsRef?.scrollTop ?? 0))], + els: [copy(detailsRef, detailsFrame), copy(listRef, optionsTrack, -scroll.top)], details: content.details, list: content.list, + scroll: scroll.top, } into = back ? track(leaving(back, v, into), 0, speed(back), options, v.launch()) @@ -789,18 +847,26 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => if (detailsFrame && content.details > 0) { const shown = boxes.details.value detailsFrame.style.height = sized ? `${shown.toFixed(2)}px` : "" - mask(detailsFrame, Math.max(content.details, ghost?.details ?? 0) - shown, shown, sliding) + mask(detailsFrame, 0, Math.max(content.details, ghost?.details ?? 0) - shown, shown, sliding) } if (optionsRef && content.list > 0) { - const shown = boxes.list.value - optionsRef.style.height = sized ? `${(shown * (1 - folded)).toFixed(2)}px` : folded > 0 ? "0px" : "" - mask(optionsRef, Math.max(content.list, ghost?.list ?? 0) - shown, shown, sliding) + // Without motion the dock's max height caps the answers box, which scrolls inside the room it leaves. + const shown = sized + ? Math.min(boxes.list.value, room()) * (1 - folded) + : folded > 0 + ? 0 + : Math.min(content.list, room()) + + optionsRef.style.height = sized ? `${shown.toFixed(2)}px` : folded > 0 ? "0px" : "" + scroll.view = shown + feathers() } if (optionsRef) { optionsRef.style.opacity = folded > 0 ? (1 - folded).toFixed(4) : "" - optionsRef.style.visibility = folded > 0.98 ? "hidden" : "" + // Restoring shows the answers again at once, so the highlighted one can take focus. + optionsRef.style.visibility = folded > 0.98 && boxes.folded.target > 0 ? "hidden" : "" } if (chevron) chevron.style.transform = folded > 0 ? `rotate(${(folded * 180).toFixed(2)}deg)` : "" @@ -810,6 +876,15 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => paintFooter() } + // The answers fade out at whichever edge has more of them past it, while they open or scroll. + // It reads where the list is scrolled from the last scroll, never from layout, since it runs every frame. + const feathers = () => { + if (!optionsRef) return + const above = scroll.top + const shown = scroll.view + mask(optionsRef, above, Math.max(content.list, ghost?.list ?? 0) - above - shown, shown, !!(into || ghost)) + } + const paintFooter = () => { if (backBox) { const shown = unit(foot.back.value) @@ -924,7 +999,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => }) optsRef.length = options().length - if (optionsRef) optionsRef.scrollTop = 0 + if (optionsRef) optionsRef.scrollTop = scroll.top = 0 const reduced = !questionMotion() @@ -951,9 +1026,13 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => return } - // The answers box is still springing to the new list's height, so focusing must not scroll it. + // The answers box is still springing to the new list's height, so the answer that takes focus comes into view of + // the height it will rest at. focus(pickFocus(tab), { preventScroll: true }) + if (optionsRef && detailsRef) + reveal(rowAt(store.focus), Math.min(optionsRef.scrollHeight, settled(detailsRef.offsetHeight))) + wake() } @@ -993,7 +1072,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => if (sending()) return setStore("minimized", false) fold(0) - focus(pickFocus()) + paint() + // The answers box is still opening, so its answer comes into view of the height it will rest at. + const index = pickFocus() + focus(index, { preventScroll: true }) + + if (detailsRef) reveal(rowAt(index), Math.min(content.list, settled(detailsRef.offsetHeight))) } const fold = (target: number) => { @@ -1189,6 +1273,11 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () => aria-label={question()?.question} inert={store.minimized} aria-hidden={store.minimized ? "true" : undefined} + onScroll={(event) => { + scroll.top = event.currentTarget.scrollTop + pin() + feathers() + }} // Space already acted on key down; its release must not click the button again. onKeyUp={(event) => { if (event.key !== " ") return @@ -1397,13 +1486,15 @@ function place(el: HTMLElement, look: Look) { // While a switch is under way, content sliding into a box's side gutters fades out there. While a box is shorter than // what it holds, its bottom fades instead of ending hard, over no more than a third of the box. -function mask(el: HTMLElement, cut: number, shown: number, sliding: boolean) { - const bottom = Math.min(24, cut, shown / 3) +function mask(el: HTMLElement, above: number, below: number, shown: number, sliding: boolean) { + const top = Math.min(feather, above, shown / 3) + const bottom = Math.min(feather, below, shown / 3) const layers = [ sliding ? "linear-gradient(to right, transparent, #000 var(--question-gutter), #000 calc(100% - var(--question-gutter)), transparent)" : "", + top > 0.5 ? `linear-gradient(to bottom, transparent, #000 ${top.toFixed(2)}px)` : "", bottom > 0.5 ? `linear-gradient(to top, transparent, #000 ${bottom.toFixed(2)}px)` : "", ].filter(Boolean) @@ -1433,3 +1524,11 @@ function copy(el: HTMLElement, host: HTMLElement, top = 0) { return clone } + +// The nearest of `el` and its ancestors that has a height. +function bounds(el: Element | null | undefined): DOMRect | undefined { + if (!el) return undefined + const rect = el.getBoundingClientRect() + + return rect.height >= 1 ? rect : bounds(el.parentElement) +} diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 3f2152a6e2e..5aa1e8553cb 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -285,14 +285,17 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void "hidden md:flex": state.reviewOpened, }} > -
- + {/* The padding is inside the scroller, so a tall dock below can shrink the timeline to nothing. */} +
+
+ +