产品实测:生时校正答了三题后 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
152 lines
6.0 KiB
TypeScript
152 lines
6.0 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { CharacterRemaining } from "@/components/character-remaining";
|
|
import { characterRemainingVisible } from "@/lib/character-remaining";
|
|
import {
|
|
choiceQuestionGroups,
|
|
choiceSelectionIntent,
|
|
normalizeUnmatchedNote,
|
|
rangeLabel,
|
|
} from "@/lib/birth-time-choice-question-model";
|
|
import type {
|
|
DynamicJourneyProgress,
|
|
PublicDynamicChoiceQuestion,
|
|
} from "@/lib/birth-time-journey-turn-protocol";
|
|
|
|
type SharedQuestionProps = {
|
|
readonly progress: DynamicJourneyProgress;
|
|
readonly pending: boolean;
|
|
readonly error: string;
|
|
readonly onPause: () => void;
|
|
readonly onFinish: () => void;
|
|
};
|
|
|
|
type ChoiceQuestionProps = SharedQuestionProps & {
|
|
readonly question: PublicDynamicChoiceQuestion;
|
|
readonly onSelect: (optionId: string) => void;
|
|
};
|
|
|
|
type ClarificationProps = SharedQuestionProps & {
|
|
readonly onReframe: (note: string) => void;
|
|
};
|
|
|
|
export function BirthTimeQuestionProgress({ progress }: {
|
|
readonly progress: DynamicJourneyProgress;
|
|
}) {
|
|
return (
|
|
<div className="birth-time-question-progress">
|
|
<b>已完成 {progress.effectiveAnswerCount} 个有效判断</b>
|
|
<span>当前候选范围:{rangeLabel(progress.currentRange)}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function BirthTimeChoiceQuestion(props: ChoiceQuestionProps) {
|
|
const groups = choiceQuestionGroups(props.question);
|
|
const [selectedId, setSelectedId] = useState("");
|
|
const firstChoiceRef = useRef<HTMLButtonElement | null>(null);
|
|
useEffect(() => {
|
|
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
|
/* 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]) => {
|
|
const intent = choiceSelectionIntent(option);
|
|
setSelectedId(intent.optionId);
|
|
props.onSelect(intent.optionId);
|
|
};
|
|
|
|
return (
|
|
<div className="birth-time-choice-surface" aria-busy={props.pending}>
|
|
<BirthTimeQuestionProgress progress={props.progress} />
|
|
<fieldset className="birth-time-choice-question" disabled={props.pending}>
|
|
<legend>{props.question.prompt}</legend>
|
|
<div className="birth-time-primary-choices">
|
|
{groups.primary.map((option, index) => (
|
|
<button
|
|
className="birth-time-choice-option is-primary"
|
|
data-selected={selectedId === option.optionId}
|
|
key={option.optionId}
|
|
onClick={() => select(option)}
|
|
ref={index === 0 ? firstChoiceRef : undefined}
|
|
type="button"
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="birth-time-special-choices">
|
|
{[groups.unknown, groups.unmatched].map((option) => (
|
|
<button
|
|
className="birth-time-choice-option is-secondary"
|
|
data-selected={selectedId === option.optionId}
|
|
key={option.optionId}
|
|
onClick={() => select(option)}
|
|
type="button"
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
{props.pending && <p className="birth-time-choice-pending" role="status">正在缩小候选范围…</p>}
|
|
<JourneyControls {...props} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const unmatchedRemainingId = "birth-time-unmatched-remaining";
|
|
|
|
export function BirthTimeUnmatchedClarification(props: ClarificationProps) {
|
|
const [note, setNote] = useState("");
|
|
const reframeRef = useRef<HTMLButtonElement | null>(null);
|
|
useEffect(() => {
|
|
if (!props.pending && (props.progress.answeredCount > 0 || props.error)) {
|
|
reframeRef.current?.focus({ preventScroll: true });
|
|
}
|
|
}, [props.error, props.pending, props.progress.answeredCount]);
|
|
return (
|
|
<div className="birth-time-choice-surface" aria-busy={props.pending}>
|
|
<BirthTimeQuestionProgress progress={props.progress} />
|
|
<fieldset className="birth-time-choice-question" disabled={props.pending}>
|
|
<legend>这道题都不符合你的情况吗?</legend>
|
|
<label className="birth-time-unmatched-note">
|
|
<span className="character-remaining-row">
|
|
补充一句(可选)
|
|
<CharacterRemaining id={unmatchedRemainingId} length={note.length} maxLength={240} />
|
|
</span>
|
|
<textarea
|
|
maxLength={240}
|
|
aria-describedby={characterRemainingVisible(note.length, 240) ? unmatchedRemainingId : undefined}
|
|
onChange={(event) => setNote(event.target.value)}
|
|
placeholder="例如:这段变化发生得更早,或情况不太一样"
|
|
value={note}
|
|
/>
|
|
</label>
|
|
<div className="birth-time-reframe-actions">
|
|
<button className="button-secondary birth-time-guided-action" onClick={() => props.onReframe("")} ref={reframeRef} type="button">换一道题</button>
|
|
<button className="button-primary birth-time-guided-action" onClick={() => props.onReframe(normalizeUnmatchedNote(note))} type="button">提交补充并换题</button>
|
|
</div>
|
|
</fieldset>
|
|
{props.pending && <p className="birth-time-choice-pending" role="status">正在换一道更合适的问题…</p>}
|
|
<JourneyControls {...props} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function JourneyControls(props: SharedQuestionProps) {
|
|
return (
|
|
<>
|
|
<div className="birth-time-journey-controls">
|
|
<button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onPause} type="button">暂停,稍后继续</button>
|
|
<button className="button-text birth-time-guided-action" disabled={props.pending} onClick={props.onFinish} type="button">结束并保存当前范围</button>
|
|
</div>
|
|
{props.error && <p className="form-error" role="alert">{props.error}</p>}
|
|
</>
|
|
);
|
|
}
|