e8fa1ce4ea
Dark muted surfaces missed WCAG AA; action and tertiary tokens plus a 32-pair contract close that. Hit targets, remaining-count, and Enter-to-send follow the interaction audit without changing visual sizes. Co-authored-by: Cursor <cursoragent@cursor.com>
149 lines
5.8 KiB
TypeScript
149 lines
5.8 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)) {
|
||
firstChoiceRef.current?.focus();
|
||
}
|
||
}, [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();
|
||
}
|
||
}, [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>}
|
||
</>
|
||
);
|
||
}
|