Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 22 additions & 21 deletions packages/app/e2e/helpers/questions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,50 +10,51 @@ export async function expectCurrentQuestion(
): Promise<void> {
const card = page.getByTestId("question-form-card").first();
await expect(card.getByTestId("question-form-current-question")).toHaveText(input.question);
await expect(
card.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toHaveAttribute("aria-selected", "true");
// Nav tabs only render for multi-question cards (hidden for a lone question).
if (input.total > 1) {
await expect(questionNavTab(page, input)).toHaveAttribute("aria-selected", "true");
}
}

export async function expectQuestionHidden(page: Page, question: string): Promise<void> {
await expect(page.getByText(question, { exact: true })).toHaveCount(0);
}

export async function chooseQuestionOption(page: Page, option: string): Promise<void> {
await page
// Options render as radios (single-select) or checkboxes (multi-select), so match
// 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")
.first()
.getByRole("button", { name: option })
.click();
.getByRole("tab", { name: `Question ${input.index} of ${input.total}` });
}

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> {
await expect(
page.getByTestId("question-form-card").first().getByRole("button", { name: option }),
).toHaveAttribute("aria-selected", "true");
await expect(questionOption(page, option)).toHaveAttribute("aria-checked", "true");
}

export async function openQuestion(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` })
.click();
await questionNavTab(page, input).click();
}

export async function expectQuestionNavigationEnabled(
page: Page,
input: { index: number; total: number },
): Promise<void> {
await expect(
page
.getByTestId("question-form-card")
.first()
.getByRole("button", { name: `Question ${input.index} of ${input.total}` }),
).toBeEnabled();
await expect(questionNavTab(page, input)).toBeEnabled();
}

export async function fillQuestionAnswer(
Expand Down
203 changes: 144 additions & 59 deletions packages/app/src/components/question-form-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,30 +92,49 @@ function QuestionOptionRow({
() => [styles.optionDescription, { color: 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 (
<Pressable
style={pressableStyle}
onPress={handlePress}
disabled={isResponding}
accessibilityRole="button"
accessibilityRole={multiSelect ? "checkbox" : "radio"}
accessibilityLabel={option.label}
accessibilityState={accessibilityState}
aria-selected={isSelected}
aria-checked={isSelected}
>
<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}>
<Text style={optionLabelStyle}>{option.label}</Text>
{option.description ? (
<Text style={optionDescriptionStyle}>{option.description}</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
Expand All @@ -124,15 +143,19 @@ function QuestionOptionRow({
interface QuestionNavButtonProps {
index: number;
total: number;
header: string;
isActive: boolean;
isAnswered: boolean;
isResponding: boolean;
onSelect: (index: number) => void;
}

function QuestionNavButton({
index,
total,
header,
isActive,
isAnswered,
isResponding,
onSelect,
}: QuestionNavButtonProps) {
Expand Down Expand Up @@ -171,7 +194,7 @@ function QuestionNavButton({

return (
<Pressable
accessibilityRole="button"
accessibilityRole="tab"
accessibilityLabel={`Question ${index + 1} of ${total}`}
accessibilityState={accessibilityState}
aria-selected={isActive}
Expand All @@ -180,11 +203,61 @@ function QuestionNavButton({
onPress={handlePress}
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>
);
}

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 {
qIndex: number;
accessibilityLabel: string;
Expand Down Expand Up @@ -355,6 +428,12 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
setActiveQuestionIndex(index);
}, []);

const navIsAnswered = useCallback(
(qIndex: number) =>
questions ? isQuestionAnswered(questions[qIndex], qIndex, selections, otherTexts) : false,
[questions, selections, otherTexts],
);

const handlePrimaryAction = useCallback(() => {
if (!isLastQuestion) {
if (!activeQuestionAnswered || isResponding) return;
Expand Down Expand Up @@ -407,9 +486,16 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
() => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const questionNavStyle = useMemo(
() => [styles.questionNav, isMobile && styles.questionNavMobile],
[isMobile],
// Single-select radios need a group; checkboxes are valid standalone.
const optionsGroupAccessibility = useMemo(
() =>
activeQuestion && !activeQuestion.multiSelect
? ({
accessibilityRole: "radiogroup",
accessibilityLabel: activeQuestion.question,
} as const)
: {},
[activeQuestion],
);
const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
Expand All @@ -436,33 +522,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi

return (
<View style={containerStyle} testID="question-form-card">
<View style={styles.questionTopRow}>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>
<View style={questionNavStyle} testID="question-form-question-nav">
{questions.map((question, qIndex) => {
const isActive = qIndex === resolvedActiveQuestionIndex;
return (
<QuestionNavButton
key={question.header}
index={qIndex}
total={questions.length}
isActive={isActive}
isResponding={isResponding}
onSelect={handleSelectQuestion}
/>
);
})}
</View>
<QuestionNav
questions={questions}
activeIndex={resolvedActiveQuestionIndex}
isAnswered={navIsAnswered}
isResponding={isResponding}
onSelect={handleSelectQuestion}
/>
<View style={styles.questionHeader}>
<Text testID="question-form-current-question" style={questionTextStyle}>
{activeQuestion?.question}
</Text>
</View>

{activeQuestion ? (
<View key={activeQuestion.question} style={styles.questionBlock}>
{activeQuestion.options.length > 0 ? (
<View style={styles.optionsWrap}>
<View style={styles.optionsWrap} {...optionsGroupAccessibility}>
{activeQuestion.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
Expand Down Expand Up @@ -546,12 +622,6 @@ const styles = StyleSheet.create((theme) => ({
questionBlock: {
gap: theme.spacing[2],
},
questionTopRow: {
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
gap: theme.spacing[3],
},
questionHeader: {
flexDirection: "row",
alignItems: "center",
Expand All @@ -571,24 +641,24 @@ const styles = StyleSheet.create((theme) => ({
},
questionNav: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "flex-end",
gap: theme.spacing[1],
},
questionNavMobile: {
paddingRight: theme.spacing[1],
paddingHorizontal: theme.spacing[3],
},
questionNavButton: {
minWidth: 28,
height: 28,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
borderRadius: 999,
gap: theme.spacing[1],
minHeight: 28,
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
},
questionNavText: {
fontSize: theme.fontSize.xs,
fontWeight: "700",
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
optionItem: {
flexDirection: "row",
Expand All @@ -603,25 +673,40 @@ const styles = StyleSheet.create((theme) => ({
optionItemContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
gap: theme.spacing[2],
},
optionTextBlock: {
flex: 1,
gap: 2,
gap: theme.spacing[1],
},
optionLabel: {
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
lineHeight: 22,
},
optionDescription: {
fontSize: theme.fontSize.xs,
lineHeight: 16,
fontSize: theme.fontSize.sm,
lineHeight: 20,
},
optionCheckSlot: {
width: 16,
selectionControl: {
width: 18,
height: 18,
alignItems: "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: {
borderWidth: 1,
Expand Down
Loading