Files
Jyotisha/frontend/src/components/birth-time-choice-question.tsx
T
Jesse_Chen 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
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>}
</>
);
}