@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user