feat(app): clearer interactive question card

Fixes #1642
This commit is contained in:
Christoph Leiter
2026-06-30 22:53:12 +02:00
committed by GitHub
parent 20385bdb50
commit 2b46caa20e
2 changed files with 166 additions and 80 deletions
+22 -21
View File
@@ -10,50 +10,51 @@ export async function expectCurrentQuestion(
): Promise<void> { ): Promise<void> {
const card = page.getByTestId("question-form-card").first(); const card = page.getByTestId("question-form-card").first();
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question); await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
await expect( // Nav tabs only render for multi-question cards (hidden for a lone question).
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }), if (input.total > 1) {
).toHaveAttribute("aria-selected", "true"); await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
}
} }
export async function expectQuestionHidden(page: Page, question: string): Promise<void> { export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0); await expect(page.getByText(question, { exact: true })).toHaveCount(0);
} }
export async function chooseQuestionOption(page: Page, option: string): Promise<void> { // Options render as radios (single-select) or checkboxes (multi-select), so match
await page // either role by accessible name.
function questionOption(page: Page, option: string) {
const card = page.getByTestId("question-form-card").first();
return card.getByRole("radio", { name: option }).or(card.getByRole("checkbox", { name: option }));
}
// The multi-question nav renders as a tablist; each question is a tab.
function questionNavTab(page: Page, input: { index: number; total: number }) {
return page
.getByTestId("question-form-card") .getByTestId("question-form-card")
.first() .first()
.getByRole("button", { name: option }) .getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
.click(); }
export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await questionOption(page, option).click();
} }
export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> { export async function expectQuestionOptionSelected(page: Page, option: string): Promise<void> {
await expect( await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
).toHaveAttribute("aria-selected", "true");
} }
export async function openQuestion( export async function openQuestion(
page: Page, page: Page,
input: { index: number; total: number }, input: { index: number; total: number },
): Promise<void> { ): Promise<void> {
await page await questionNavTab(page, input).click();
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
.click();
} }
export async function expectQuestionNavigationEnabled( export async function expectQuestionNavigationEnabled(
page: Page, page: Page,
input: { index: number; total: number }, input: { index: number; total: number },
): Promise<void> { ): Promise<void> {
await expect( await expect(questionNavTab(page, input)).toBeEnabled();
page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toBeEnabled();
} }
export async function fillQuestionAnswer( export async function fillQuestionAnswer(
@@ -92,30 +92,49 @@ function QuestionOptionRow({
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }], () => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted], [theme.colors.foregroundMuted],
); );
const accessibilityState = useMemo(() => ({ selected: isSelected }), [isSelected]); const accessibilityState = useMemo(() => ({ checked: isSelected }), [isSelected]);
// Static left-side control: square for multi-select, circle for single-select.
// Always rendered so toggling only swaps fill/border — the row never reflows.
const controlStyle = useMemo(
() => [
styles.selectionControl,
multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio,
{
borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted,
backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent",
},
],
[isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted],
);
const radioDotStyle = useMemo(
() => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }],
[theme.colors.accent],
);
return ( return (
<Pressable <Pressable
style={pressableStyle} style={pressableStyle}
onPress={handlePress} onPress={handlePress}
disabled={isResponding} disabled={isResponding}
accessibilityRole="button" accessibilityRole={multiSelect ? "checkbox" : "radio"}
accessibilityLabel={option.label} accessibilityLabel={option.label}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
aria-selected={isSelected} aria-checked={isSelected}
> >
<View style={styles.optionItemContent}> <View style={styles.optionItemContent}>
<View style={controlStyle}>
{isSelected && multiSelect ? (
<Check size={12} color={theme.colors.accentForeground} />
) : null}
{isSelected && !multiSelect ? <View style={radioDotStyle} /> : null}
</View>
<View style={styles.optionTextBlock}> <View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text> <Text style={optionLabelStyle}>{option.label}</Text>
{option.description ? ( {option.description ? (
<Text style={optionDescriptionStyle}>{option.description}</Text> <Text style={optionDescriptionStyle}>{option.description}</Text>
) : null} ) : null}
</View> </View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View> </View>
</Pressable> </Pressable>
); );
@@ -124,7 +143,9 @@ function QuestionOptionRow({
interface QuestionNavButtonProps { interface QuestionNavButtonProps {
index: number; index: number;
total: number; total: number;
header: string;
isActive: boolean; isActive: boolean;
isAnswered: boolean;
isResponding: boolean; isResponding: boolean;
onSelect: (index: number) => void; onSelect: (index: number) => void;
} }
@@ -132,7 +153,9 @@ interface QuestionNavButtonProps {
function QuestionNavButton({ function QuestionNavButton({
index, index,
total, total,
header,
isActive, isActive,
isAnswered,
isResponding, isResponding,
onSelect, onSelect,
}: QuestionNavButtonProps) { }: QuestionNavButtonProps) {
@@ -171,7 +194,7 @@ function QuestionNavButton({
return ( return (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="tab"
accessibilityLabel={`Question ${index + 1} of ${total}`} accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState} accessibilityState={accessibilityState}
aria-selected={isActive} aria-selected={isActive}
@@ -180,11 +203,61 @@ function QuestionNavButton({
onPress={handlePress} onPress={handlePress}
disabled={isResponding} disabled={isResponding}
> >
<Text style={textStyle}>{index + 1}</Text> {isAnswered ? (
<Check
size={12}
color={isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : null}
<Text style={textStyle} numberOfLines={1}>
{header}
</Text>
</Pressable> </Pressable>
); );
} }
interface QuestionNavProps {
questions: QuestionFormQuestion[];
activeIndex: number;
isAnswered: (qIndex: number) => boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}
// Titled tabs (one per question header) with a check on answered ones. Hidden for
// a lone question — a single "1 of 1" tab carries no information.
function QuestionNav({
questions,
activeIndex,
isAnswered,
isResponding,
onSelect,
}: QuestionNavProps) {
if (questions.length <= 1) {
return null;
}
return (
<View
style={styles.questionNav}
testID="question-form-question-nav"
accessibilityRole="tablist"
>
{questions.map((question, qIndex) => (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
header={question.header}
isActive={qIndex === activeIndex}
isAnswered={isAnswered(qIndex)}
isResponding={isResponding}
onSelect={onSelect}
/>
))}
</View>
);
}
interface QuestionOtherInputProps { interface QuestionOtherInputProps {
qIndex: number; qIndex: number;
accessibilityLabel: string; accessibilityLabel: string;
@@ -355,6 +428,12 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
setActiveQuestionIndex(index); setActiveQuestionIndex(index);
}, []); }, []);
const navIsAnswered = useCallback(
(qIndex: number) =>
questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false,
[questions, selections, otherTexts],
);
const handlePrimaryAction = useCallback(() => { const handlePrimaryAction = useCallback(() => {
if (!isLastQuestion) { if (!isLastQuestion) {
if (!activeQuestionAnswered || isResponding) return; if (!activeQuestionAnswered || isResponding) return;
@@ -407,9 +486,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.questionText, { color: theme.colors.foreground }], () => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground], [theme.colors.foreground],
); );
const questionNavStyle = useMemo( // Single-select radios need a group; checkboxes are valid standalone.
() => [styles.questionNav, isMobile && styles.questionNavMobile], const optionsGroupAccessibility = useMemo(
[isMobile], () =>
activeQuestion && !activeQuestion.multiSelect
? ({
accessibilityRole: "radiogroup",
accessibilityLabel: activeQuestion.question,
} as const)
: {},
[activeQuestion],
); );
const actionsContainerStyle = useMemo( const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop], () => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
@@ -436,33 +522,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
return ( return (
<View style={containerStyle} testID="question-form-card"> <View style={containerStyle} testID="question-form-card">
<View style={styles.questionTopRow}> <QuestionNav
<View style={styles.questionHeader}> questions={questions}
<Text testID="question-form-current-question" style={questionTextStyle}> activeIndex={resolvedActiveQuestionIndex}
{activeQuestion?.question} isAnswered={navIsAnswered}
</Text> isResponding={isResponding}
</View> onSelect={handleSelectQuestion}
<View style={questionNavStyle} testID="question-form-question-nav"> />
{questions.map((question, qIndex) => { <View style={styles.questionHeader}>
const isActive = qIndex === resolvedActiveQuestionIndex; <Text testID="question-form-current-question" style={questionTextStyle}>
return ( {activeQuestion?.question}
<QuestionNavButton </Text>
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onSelect={handleSelectQuestion}
/>
);
})}
</View>
</View> </View>
{activeQuestion ? ( {activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}> <View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? ( {activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap}> <View style={styles.optionsWrap} {...optionsGroupAccessibility}>
{activeQuestion.options.map((opt, optIndex) => ( {activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow <QuestionOptionRow
key={opt.label} key={opt.label}
@@ -546,12 +622,6 @@ const styles = StyleSheet.create((theme) => ({
questionBlock: { questionBlock: {
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
questionTopRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[3],
},
questionHeader: { questionHeader: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -571,24 +641,24 @@ const styles = StyleSheet.create((theme) => ({
}, },
questionNav: { questionNav: {
flexDirection: "row", flexDirection: "row",
flexWrap: "wrap",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1], gap: theme.spacing[1],
}, paddingHorizontal: theme.spacing[3],
questionNavMobile: {
paddingRight: theme.spacing[1],
}, },
questionNavButton: { questionNavButton: {
minWidth: 28, flexDirection: "row",
height: 28,
alignItems: "center", alignItems: "center",
justifyContent: "center", gap: theme.spacing[1],
borderRadius: 999, minHeight: 28,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
}, },
questionNavText: { questionNavText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
fontWeight: "700", fontWeight: theme.fontWeight.medium,
}, },
optionItem: { optionItem: {
flexDirection: "row", flexDirection: "row",
@@ -603,25 +673,40 @@ const styles = StyleSheet.create((theme) => ({
optionItemContent: { optionItemContent: {
flex: 1, flex: 1,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
optionTextBlock: { optionTextBlock: {
flex: 1, flex: 1,
gap: 2, gap: theme.spacing[1],
}, },
optionLabel: { optionLabel: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
lineHeight: 22,
}, },
optionDescription: { optionDescription: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
lineHeight: 16, lineHeight: 20,
}, },
optionCheckSlot: { selectionControl: {
width: 16, width: 18,
height: 18,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
marginLeft: "auto", borderWidth: theme.borderWidth[1],
marginTop: 2, // optical-align 18px control to the 22px label first line
},
selectionControlCheckbox: {
borderRadius: theme.borderRadius.base,
},
selectionControlRadio: {
borderRadius: 999,
},
selectionRadioDot: {
width: 8,
height: 8,
borderRadius: 999,
}, },
otherInput: { otherInput: {
borderWidth: 1, borderWidth: 1,