Files
Jyotisha/frontend/src/components/birth-time-choice-question.tsx
T
Jesse_ChenandCursor e8fa1ce4ea
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 9m54s
fix(frontend): raise dark contrast, expand hit areas, and surface input limits
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>
2026-08-30 11:19:50 +08:00

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>}
</>
);
}