@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -124,7 +143,9 @@ function QuestionOptionRow({
|
||||
interface QuestionNavButtonProps {
|
||||
index: number;
|
||||
total: number;
|
||||
header: string;
|
||||
isActive: boolean;
|
||||
isAnswered: boolean;
|
||||
isResponding: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
}
|
||||
@@ -132,7 +153,9 @@ interface QuestionNavButtonProps {
|
||||
function QuestionNavButton({
|
||||
index,
|
||||
total,
|
||||
header,
|
||||
isActive,
|
||||
isAnswered,
|
||||
isResponding,
|
||||
onSelect,
|
||||
}: QuestionNavButtonProps) {
|
||||
@@ -171,7 +194,7 @@ function QuestionNavButton({
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityRole="tab"
|
||||
accessibilityLabel={`Question ${index + 1} of ${total}`}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={isActive}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
@@ -436,33 +522,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
|
||||
return (
|
||||
<View style={containerStyle} testID="question-form-card">
|
||||
<View style={styles.questionTopRow}>
|
||||
<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>
|
||||
<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>
|
||||
</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}
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user