mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 11:39:41 +03:00
feat(app): feather the question dock's scroll edge (#59395)
This commit is contained in:
committed by
GitHub
Unverified
parent
82b572439c
commit
8a450706f0
@@ -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<Locator>) => {
|
||||
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<HTMLElement>('[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<HTMLElement>('[data-slot="question-option"]')!
|
||||
const box = input.closest<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-slot="question-options"]')!
|
||||
root.querySelector<HTMLElement>('[data-slot="question-header-actions"] > button:last-of-type')!.click()
|
||||
|
||||
return Array.from({ length: 45 }).reduce<Promise<{ height: number; asked: number; given: number }[]>>(
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: () => (
|
||||
<SessionPreview
|
||||
title="Polish the docs site"
|
||||
description="opencode · docs"
|
||||
document={questionPendingDocument}
|
||||
request={{
|
||||
type: "question",
|
||||
value: {
|
||||
...activeQuestionRequest,
|
||||
fields: [
|
||||
{
|
||||
key: "details",
|
||||
type: "multiselect",
|
||||
title: "Details",
|
||||
description: "Which should the builder add next? Pick any.",
|
||||
options: [
|
||||
{
|
||||
value: "typography",
|
||||
label: "All typography (Recommended)",
|
||||
description:
|
||||
"Hanging punctuation, tabular figures, typesetting fixes, one label style, accent selection",
|
||||
},
|
||||
{ value: "links", label: "Heading links", description: "Hover shows #, click copies the section link" },
|
||||
{
|
||||
value: "contents",
|
||||
label: "Sticky contents list",
|
||||
description: "Section list in the margin on wide screens, highlights where you are",
|
||||
},
|
||||
{ value: "sidenotes", label: "Sidenotes", description: "Margin notes on desktop, inline on phone" },
|
||||
{ value: "glossary", label: "Glossary terms", description: "Dotted underline, definition on hover" },
|
||||
{ value: "fade", label: "Scroll fade", description: "Fade the edge of anything that scrolls sideways" },
|
||||
{ value: "code", label: "Highlighted code lines", description: "Tint the lines that matter" },
|
||||
{ value: "verdict", label: "Verdict line", description: "Answer or decision first, in the header" },
|
||||
{
|
||||
value: "checked",
|
||||
label: "Checked / not checked",
|
||||
description: "Marker for verified vs unverified claims",
|
||||
},
|
||||
{
|
||||
value: "diagram",
|
||||
label: "Diagram interaction",
|
||||
description: "Hover highlights connections, click enlarges",
|
||||
},
|
||||
{ value: "footer", label: "Footer", description: "Source path to copy, plus the date it was made" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ship",
|
||||
type: "string",
|
||||
title: "Ship",
|
||||
description: "Ship them in one pull request?",
|
||||
options: [
|
||||
{ value: "one", label: "One pull request" },
|
||||
{ value: "split", label: "One per detail" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -285,14 +285,17 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
|
||||
"hidden md:flex": state.reviewOpened,
|
||||
}}
|
||||
>
|
||||
<div data-slot="story-timeline" class="min-h-0 flex-1 overflow-y-auto py-6">
|
||||
<SessionTimeline
|
||||
document={props.document}
|
||||
timelineDetail={props.timelineDetail}
|
||||
editToolDefaultOpen
|
||||
shellToolDefaultOpen
|
||||
class="mx-auto w-full max-w-[840px]"
|
||||
/>
|
||||
{/* The padding is inside the scroller, so a tall dock below can shrink the timeline to nothing. */}
|
||||
<div data-slot="story-timeline" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="py-6">
|
||||
<SessionTimeline
|
||||
document={props.document}
|
||||
timelineDetail={props.timelineDetail}
|
||||
editToolDefaultOpen
|
||||
shellToolDefaultOpen
|
||||
class="mx-auto w-full max-w-[840px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SessionComposerRegion
|
||||
controller={region}
|
||||
|
||||
Reference in New Issue
Block a user