Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Display navigation controls within forms #400

Merged
merged 5 commits into from
Feb 19, 2025
Merged
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
5 changes: 5 additions & 0 deletions .changeset/cold-trains-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Add navigation controls for form questions
33 changes: 33 additions & 0 deletions src/components/forms/FormContainer/FormContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { FormContainer } from "@/components/forms";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

describe("FormContainer", () => {
it("renders children content", () => {
render(
<FormContainer>
<div>Test Content</div>
</FormContainer>,
);

expect(screen.getByText("Test Content")).toBeInTheDocument();
});

it("renders back button", async () => {
render(<FormContainer />);

const backButton = screen.getByLabelText("Save and exit");
expect(backButton).toBeInTheDocument();
});

it("renders form navigation component", () => {
render(<FormContainer />);

// Check for navigation controls that FormNavigation renders
expect(screen.getByLabelText("Previous question")).toBeInTheDocument();
expect(screen.getByLabelText("Next question")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "All questions" }),
).toBeInTheDocument();
});
});
2 changes: 2 additions & 0 deletions src/components/forms/FormContainer/FormContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Tooltip,
TooltipTrigger,
} from "@/components/common";
import { FormNavigation } from "@/components/forms";
import { useRouter } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";

Expand Down Expand Up @@ -32,6 +33,7 @@ export function FormContainer({ children }: FormContainerProps) {
/>
<Tooltip placement="right">Save and exit</Tooltip>
</TooltipTrigger>
<FormNavigation />
</main>
);
}
98 changes: 98 additions & 0 deletions src/components/forms/FormNavigation/FormNavigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { FormNavigation, FormSection } from "@/components/forms";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

describe("FormNavigation", () => {
const mockSections = ["First Question", "Second Question", "Third Question"];

beforeEach(() => {
// Mock the DOM structure that FormNavigation expects
for (const title of mockSections) {
render(<FormSection title={title} />);
}

// Mock IntersectionObserver
const mockIntersectionObserver = vi.fn();
mockIntersectionObserver.mockReturnValue({
observe: () => null,
disconnect: () => null,
});
window.IntersectionObserver = mockIntersectionObserver;
});

afterEach(() => {
vi.clearAllMocks();
});

it("renders navigation controls", () => {
render(<FormNavigation />);

expect(screen.getByLabelText("Previous question")).toBeInTheDocument();
expect(screen.getByLabelText("Next question")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "All questions" }),
).toBeInTheDocument();
});

it("renders all questions in menu popover", async () => {
render(<FormNavigation />);

userEvent.click(screen.getByRole("button", { name: "All questions" }));

const menu = await screen.findByRole("menu");
expect(menu).toBeInTheDocument();

for (const title of mockSections) {
expect(screen.getByRole("menuitem", { name: title })).toBeInTheDocument();
}
});

it("disables previous button when at first section", async () => {
render(<FormNavigation />);

// Simulate first section being visible
const observer = window.IntersectionObserver.mock.calls[0][0];
observer([
{
target: document.getElementById("first-question"),
isIntersecting: true,
},
]);

await waitFor(() => {
expect(screen.getByLabelText("Previous question")).toHaveAttribute(
"aria-disabled",
"true",
);
expect(screen.getByLabelText("Next question")).not.toHaveAttribute(
"aria-disabled",
"true",
);
});
});

it("disables next button when at last section", async () => {
render(<FormNavigation />);

// Simulate last section being visible
const observer = window.IntersectionObserver.mock.calls[0][0];
observer([
{
target: document.getElementById("third-question"),
isIntersecting: true,
},
]);

await waitFor(() => {
expect(screen.getByLabelText("Previous question")).not.toHaveAttribute(
"aria-disabled",
"true",
);
expect(screen.getByLabelText("Next question")).toHaveAttribute(
"aria-disabled",
"true",
);
});
});
});
127 changes: 127 additions & 0 deletions src/components/forms/FormNavigation/FormNavigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {
Button,
Link,
Menu,
MenuItem,
MenuTrigger,
Tooltip,
TooltipTrigger,
} from "@/components/common";
import { useRouter } from "@tanstack/react-router";
import { ArrowDown, ArrowUp, Menu as MenuIcon } from "lucide-react";
import { useEffect, useState } from "react";

interface FormSection {
hash: string;
title: string;
}

export function FormNavigation() {
const [formSections, setFormSections] = useState<FormSection[]>([]);
const [activeSection, setActiveSection] = useState<{
previous: FormSection | null;
current: FormSection | null;
next: FormSection | null;
} | null>(null);
const router = useRouter();

useEffect(() => {
const formSections = Array.from(
document.querySelectorAll("[data-form-section]"),
);
setFormSections(
formSections.map((section) => ({
hash: section.id,
title: section.querySelector("[data-section-title]")?.textContent ?? "",
})),
);
}, []);

useEffect(() => {
const observers: IntersectionObserver[] = [];
const observerCallback = (entries: IntersectionObserverEntry[]) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const section = formSections.find(
(section) => section.hash === entry.target.id,
);
if (section) {
const index = formSections.findIndex(
(s) => s.hash === section.hash,
);
setActiveSection({
previous: formSections[index - 1],
current: section,
next: formSections[index + 1],
});
router.navigate({ to: ".", hash: section.hash, replace: true });
}
}
}
};

for (const section of formSections) {
const element = document.getElementById(section.hash);
if (element) {
const observer = new IntersectionObserver(observerCallback, {
threshold: 0.5,
rootMargin: "-50px 0px -50px 0px",
});
observer.observe(element);
observers.push(observer);
}
}

return () => {
for (const observer of observers) observer.disconnect();
};
}, [formSections, router]);

return (
<nav className="fixed right-4 top-4 flex gap-2">
<TooltipTrigger>
<Link
href={{ to: ".", hash: activeSection?.previous?.hash }}
routerOptions={{ replace: true }}
button={{ variant: "icon" }}
className="flex-1"
aria-label="Previous question"
isDisabled={!activeSection?.previous}
>
<ArrowUp className="size-5" />
</Link>
<Tooltip>Previous question</Tooltip>
</TooltipTrigger>
<TooltipTrigger>
<Link
href={{ to: ".", hash: activeSection?.next?.hash }}
routerOptions={{ replace: true }}
button={{ variant: "icon" }}
className="flex-1"
aria-label="Next question"
isDisabled={!activeSection?.next}
>
<ArrowDown className="size-5" />
</Link>
<Tooltip>Next question</Tooltip>
</TooltipTrigger>
<MenuTrigger>
<TooltipTrigger>
<Button variant="icon" icon={MenuIcon} aria-label="All questions" />
<Tooltip>All questions</Tooltip>
</TooltipTrigger>
<Menu>
{formSections.map(({ hash, title }) => (
<MenuItem
key={hash}
href={{ to: ".", hash }}
routerOptions={{ replace: true }}
>
{title}
</MenuItem>
))}
</Menu>
</MenuTrigger>
</nav>
);
}
20 changes: 20 additions & 0 deletions src/components/forms/FormSection/FormSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,24 @@ describe("FormSection", () => {
const descriptionQuery = screen.queryByRole("paragraph");
expect(descriptionQuery).toBeNull();
});

it("formats the question title as id and omits punctuation", () => {
render(<FormSection {...formSection} title="What is your legal name?" />);
const section = screen.getByTestId("form-section");
expect(section).toHaveAttribute("id", "what-is-your-legal-name");
});

it("omits apostrophes from the id", () => {
render(
<FormSection
{...formSection}
title="What is the reason you're changing your name?"
/>,
);
const section = screen.getByTestId("form-section");
expect(section).toHaveAttribute(
"id",
"what-is-the-reason-youre-changing-your-name",
);
});
});
20 changes: 19 additions & 1 deletion src/components/forms/FormSection/FormSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ interface FormHeaderProps {
function FormHeader({ title, description }: FormHeaderProps) {
return (
<header className="flex flex-col gap-2">
<Heading className="text-4xl font-medium text-gray-normal text-pretty">
<Heading
className="text-4xl font-medium text-gray-normal text-pretty"
data-section-title
>
{smartquotes(title)}
</Heading>
{description && (
Expand All @@ -25,6 +28,16 @@ function FormHeader({ title, description }: FormHeaderProps) {
);
}

function getQuestionId(question: string) {
let sanitizedQuestion = question;
// Remove trailing punctuation
sanitizedQuestion = sanitizedQuestion.replace(/[^\w\s]/g, "");
// Remove apostrophes
sanitizedQuestion = sanitizedQuestion.replace(/'/g, "");

return encodeURIComponent(sanitizedQuestion.toLowerCase().replace(/ /g, "-"));
}

export interface FormSectionProps extends FormHeaderProps {
/** The contents of the page. */
children?: React.ReactNode;
Expand All @@ -39,8 +52,13 @@ export function FormSection({
children,
className,
}: FormSectionProps) {
const id = getQuestionId(title);

return (
<section
id={id}
data-form-section
data-testid="form-section"
className={twMerge(
"flex flex-col gap-8 p-8 justify-center h-screen snap-center",
className,
Expand Down
1 change: 1 addition & 0 deletions src/components/forms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from "./CheckboxField/CheckboxField";
export * from "./CheckboxGroupField/CheckboxGroupField";
export * from "./EmailField/EmailField";
export * from "./FormContainer/FormContainer";
export * from "./FormNavigation/FormNavigation";
export * from "./FormSection/FormSection";
export * from "./LanguageSelectField/LanguageSelectField";
export * from "./LongTextField/LongTextField";
Expand Down
17 changes: 17 additions & 0 deletions tests/vitest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,20 @@ vi.mock("@convex-dev/auth/react", () => ({
vi.mock("@/utils/useTheme", () => ({
useTheme: vi.fn(),
}));

// Mock the useRouter hook
vi.mock("@tanstack/react-router", () => ({
useRouter: () => ({
navigate: vi.fn(),
}),
}));

// Add type for mocked IntersectionObserver
declare global {
interface Window {
IntersectionObserver: ReturnType<typeof vi.fn>;
}
}

// Update the mock assignment
window.IntersectionObserver = vi.fn() as any;
Loading