fix(rectification): 顶栏被 focus 滚出视野——面板改 clip,面板内 focus 一律 preventScroll
产品实测:生时校正答了三题后 46px 顶栏消失。devtools 取证——栅格是对的
(rows "46px 1297px"、panel y=0),但 header 自己的 rect 在 **y=-88**:
面板被程序化滚动了 88px。
根因:`overflow: hidden` 仍然是滚动容器,它只是去掉了滚动条。
BirthTimeChoiceQuestion 在每答完一题后 focus 新题的第一个选项,浏览器
为把它带进视野会滚动所有可滚动祖先,`.chat-panel` 就是其中之一——
而用户没有滚动条可以滚回来,顶栏于是永久消失。
两处都修:
- 病因:面板内 7 处程序化 focus 一律加 { preventScroll: true }。
消息区有自己的 useConversationScrollAnchor,本来就不需要浏览器代劳。
账户弹窗的 closeButton / returnTarget 不在此列——那是对话框焦点管理。
同一教训 use-billing-panel.ts 已经吃过一次(那里早写了 preventScroll)。
- 结构:.chat-panel 与 .chat-app 从 overflow:hidden 改成 overflow:clip。
clip 根本不创建滚动容器,此后任何 focus / scrollIntoView 都无法位移它。
新增 tests/chat-panel-scroll-guard.test.ts:锁住两个容器必须是 clip、
面板内不得有裸 focus(),并遍历 rectification/birth-time/chat- 全部组件,
新组件再写裸 focus 会直接打红。
测试 3372(+3),fail 仍 31 且与基线逐条一致;四个路由标记不变。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
co-authored by
Claude Opus 5
parent
20435a1916
commit
317e9f1886
@@ -48,7 +48,10 @@ export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
|
||||
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
||||
firstChoiceRef.current?.focus();
|
||||
/* preventScroll: the transcript has its own anchor; letting the browser
|
||||
scroll ancestors here scrolls `.chat-panel`, an overflow:hidden box with
|
||||
no scrollbar, and the 46px header never comes back. */
|
||||
firstChoiceRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [props.error, props.pending, props.progress.answeredCount, props.question.questionId]);
|
||||
const select = (option: PublicDynamicChoiceQuestion["options"][number]) => {
|
||||
@@ -103,7 +106,7 @@ export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
|
||||
const reframeRef = useRef<HTMLButtonElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
||||
reframeRef.current?.focus();
|
||||
reframeRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [props.error, props.pending, props.progress.answeredCount]);
|
||||
return (
|
||||
|
||||
@@ -59,7 +59,7 @@ export function BirthTimeRectification(props: BirthTimeRectificationProps) {
|
||||
if (!changed || props.journey.journeyProtocol !== "dynamic-choice-v2") return;
|
||||
const action = props.journey.nextAction;
|
||||
if (action.kind !== "ask_dynamic_choice" && action.kind !== "clarify_unmatched_answer") {
|
||||
assessmentHeadingRef.current?.focus();
|
||||
assessmentHeadingRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [props.journey]);
|
||||
if (props.journey.journeyProtocol !== "dynamic-choice-v2") {
|
||||
|
||||
@@ -1824,7 +1824,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const recalled = queued.take();
|
||||
if (!recalled) return;
|
||||
setDraft(appendQueuedText(recalled, draft));
|
||||
composer.current?.focus();
|
||||
composer.current?.focus({ preventScroll: true });
|
||||
} } : undefined}
|
||||
onSubmit={submit}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
|
||||
@@ -273,7 +273,10 @@ export function RectificationBoard({
|
||||
|
||||
useEffect(() => {
|
||||
if (!compact || !open) return;
|
||||
sheetRef.current?.focus();
|
||||
/* preventScroll for the same reason as the choice options: this sheet
|
||||
lives in the panel's grid cell, and the browser would scroll the
|
||||
panel to reach it. */
|
||||
sheetRef.current?.focus({ preventScroll: true });
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user