fix: scroll to bottom on error (#6301)

This commit is contained in:
Dhruwang Jariwala
2025-07-25 14:41:41 +05:30
committed by GitHub
parent 6ef281647a
commit 91ace0e821
6 changed files with 331 additions and 501 deletions

View File

@@ -1,93 +1,134 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/preact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyCalQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { CalEmbed } from "../general/cal-embed";
import { CalQuestion } from "./cal-question";
// Mock the CalEmbed component
vi.mock("../general/cal-embed", () => ({
CalEmbed: vi.fn(({ question }) => (
<div data-testid="cal-embed-mock">
Cal Embed for {question.calUserName}
{question.calHost && <span>Host: {question.calHost}</span>}
</div>
)),
// Mock ScrollableContainer with ref support for new functionality
vi.mock("@/components/wrappers/scrollable-container", () => {
const { forwardRef } = require("preact/compat");
const MockScrollableContainer = forwardRef(({ children }: { children: React.ReactNode }, ref: any) => {
if (ref) {
if (typeof ref === "function") {
ref({ scrollToBottom: vi.fn() });
} else if (typeof ref === "object" && ref !== null) {
ref.current = { scrollToBottom: vi.fn() };
}
}
return <div data-testid="scrollable-container">{children}</div>;
});
return {
ScrollableContainer: MockScrollableContainer,
};
});
// Mock other components - minimal mocks
vi.mock("@/components/general/cal-embed", () => ({
CalEmbed: () => <div data-testid="cal-embed">CalEmbed</div>,
}));
describe("CalQuestion", () => {
vi.mock("@/components/general/headline", () => ({
Headline: () => <h1>Headline</h1>,
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: () => <p>Subheader</p>,
}));
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel }: { buttonLabel: string }) => (
<button type="submit" data-testid="submit-button">
{buttonLabel}
</button>
),
}));
vi.mock("@/components/buttons/back-button", () => ({
BackButton: () => <button data-testid="back-button">Back</button>,
}));
// Mock lib functions
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: (value: any) => value?.default || value || "Default",
}));
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: vi.fn(() => ({ [Date.now()]: 123 })),
}));
describe("CalQuestion - New Error Handling", () => {
beforeEach(() => {
vi.stubGlobal("performance", {
now: vi.fn(() => 1000),
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});
const mockQuestion: TSurveyCalQuestion = {
id: "cal-question-1",
type: TSurveyQuestionTypeEnum.Cal,
headline: { default: "Schedule a meeting" },
subheader: { default: "Choose a time that works for you" },
required: true,
calUserName: "johndoe",
calHost: "cal.com",
};
const mockQuestionWithoutHost: TSurveyCalQuestion = {
id: "cal-question-2",
type: TSurveyQuestionTypeEnum.Cal,
headline: { default: "Schedule a meeting" },
required: false,
calUserName: "janedoe",
buttonLabel: { default: "Next" },
};
const defaultProps = {
question: mockQuestion,
value: null,
value: "",
onChange: vi.fn(),
onSubmit: vi.fn(),
isInvalid: false,
direction: "vertical" as const,
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
} as any;
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: true,
currentQuestionId: "cal-question-1",
isBackButtonHidden: false,
};
test("renders with headline and subheader", () => {
test("shows error message when required field is not filled", async () => {
render(<CalQuestion {...defaultProps} />);
expect(screen.getByText("Schedule a meeting")).toBeInTheDocument();
expect(screen.getByText("Choose a time that works for you")).toBeInTheDocument();
const submitButton = screen.getByTestId("submit-button");
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText("Please book an appointment")).toBeInTheDocument();
});
});
test("renders without subheader", () => {
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
expect(screen.getByText("Schedule a meeting")).toBeInTheDocument();
expect(screen.queryByText("Choose a time that works for you")).not.toBeInTheDocument();
});
test("renders CalEmbed component with correct props", () => {
test("error message is positioned after CalEmbed", async () => {
render(<CalQuestion {...defaultProps} />);
expect(screen.getByTestId("cal-embed-mock")).toBeInTheDocument();
expect(screen.getByText("Cal Embed for johndoe")).toBeInTheDocument();
expect(screen.getByText("Host: cal.com")).toBeInTheDocument();
expect(CalEmbed).toHaveBeenCalledWith(
expect.objectContaining({
question: mockQuestion,
onSuccessfulBooking: expect.any(Function),
}),
{}
);
const submitButton = screen.getByTestId("submit-button");
fireEvent.click(submitButton);
await waitFor(() => {
const calEmbed = screen.getByTestId("cal-embed");
const errorMessage = screen.getByText("Please book an appointment");
// Check that error message comes after CalEmbed in DOM order
expect(calEmbed.compareDocumentPosition(errorMessage)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
});
test("renders CalEmbed without host when not provided", () => {
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
test("does not show error when appointment is booked", () => {
render(<CalQuestion {...defaultProps} value="booked" />);
expect(screen.getByTestId("cal-embed-mock")).toBeInTheDocument();
expect(screen.getByText("Cal Embed for janedoe")).toBeInTheDocument();
expect(screen.queryByText(/Host:/)).not.toBeInTheDocument();
});
const submitButton = screen.getByTestId("submit-button");
fireEvent.click(submitButton);
test("does not add required indicator when question is optional", () => {
render(<CalQuestion {...defaultProps} question={mockQuestionWithoutHost} />);
expect(screen.queryByText("*")).not.toBeInTheDocument();
expect(screen.queryByText("Please book an appointment")).not.toBeInTheDocument();
});
});

View File

@@ -4,10 +4,13 @@ import { CalEmbed } from "@/components/general/cal-embed";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import {
ScrollableContainer,
type ScrollableContainerHandle,
} from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useCallback, useState } from "preact/hooks";
import { useCallback, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import { type TSurveyCalQuestion, type TSurveyQuestionId } from "@formbricks/types/surveys/types";
@@ -44,6 +47,7 @@ export function CalQuestion({
const [startTime, setStartTime] = useState(performance.now());
const isMediaAvailable = question.imageUrl || question.videoUrl;
const [errorMessage, setErrorMessage] = useState("");
const scrollableRef = useRef<ScrollableContainerHandle>(null);
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
const isCurrent = question.id === currentQuestionId;
const onSuccessfulBooking = useCallback(() => {
@@ -60,6 +64,12 @@ export function CalQuestion({
e.preventDefault();
if (question.required && !value) {
setErrorMessage("Please book an appointment");
// Scroll to bottom to show the error message
setTimeout(() => {
if (scrollableRef.current?.scrollToBottom) {
scrollableRef.current.scrollToBottom();
}
}, 100);
return;
}
@@ -70,7 +80,7 @@ export function CalQuestion({
onSubmit({ [question.id]: value }, updatedttc);
}}
className="fb-w-full">
<ScrollableContainer>
<ScrollableContainer ref={scrollableRef}>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
@@ -84,18 +94,17 @@ export function CalQuestion({
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
{errorMessage ? <span className="fb-text-red-500">{errorMessage}</span> : null}
<CalEmbed key={question.id} question={question} onSuccessfulBooking={onSuccessfulBooking} />
{errorMessage ? <span className="fb-text-red-500">{errorMessage}</span> : null}
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
{!question.required && (
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
)}
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton

View File

@@ -1,258 +1,131 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { TResponseTtc } from "@formbricks/types/responses";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurveyQuestionTypeEnum, type TSurveyRankingQuestion } from "@formbricks/types/surveys/types";
import { RankingQuestion } from "./ranking-question";
// Mock components used in the RankingQuestion component
vi.mock("@/components/buttons/back-button", () => ({
BackButton: ({ onClick, backButtonLabel }: { onClick: () => void; backButtonLabel: string }) => (
<button data-testid="back-button" onClick={onClick}>
{backButtonLabel}
</button>
),
}));
// Mock ScrollableContainer with ref support for new functionality
vi.mock("@/components/wrappers/scrollable-container", () => {
const { forwardRef } = require("preact/compat");
const MockScrollableContainer = forwardRef(({ children }: { children: React.ReactNode }, ref: any) => {
if (ref) {
if (typeof ref === "function") {
ref({ scrollToBottom: vi.fn() });
} else if (typeof ref === "object" && ref !== null) {
ref.current = { scrollToBottom: vi.fn() };
}
}
return <div data-testid="scrollable-container">{children}</div>;
});
return {
ScrollableContainer: MockScrollableContainer,
};
});
// Mock other components - minimal mocks
vi.mock("@/components/buttons/submit-button", () => ({
SubmitButton: ({ buttonLabel }: { buttonLabel: string }) => (
<button data-testid="submit-button" type="submit">
{buttonLabel}
SubmitButton: () => (
<button type="submit" data-testid="submit-button">
Submit
</button>
),
}));
vi.mock("@/components/general/headline", () => ({
Headline: ({ headline }: { headline: string }) => <h1 data-testid="headline">{headline}</h1>,
Headline: () => <h1>Headline</h1>,
}));
vi.mock("@/components/general/subheader", () => ({
Subheader: ({ subheader }: { subheader: string }) => <p data-testid="subheader">{subheader}</p>,
}));
vi.mock("@/components/general/question-media", () => ({
QuestionMedia: () => <div data-testid="question-media"></div>,
}));
vi.mock("@/components/wrappers/scrollable-container", () => ({
ScrollableContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="scrollable-container">{children}</div>
),
Subheader: () => <p>Subheader</p>,
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
// Mock lib functions
vi.mock("@/lib/i18n", () => ({
getLocalizedValue: (value: any, _: string) => (typeof value === "string" ? value : value.default),
getLocalizedValue: (value: any) => value?.default || value || "Default",
}));
vi.mock("@/lib/ttc", () => ({
useTtc: vi.fn(),
getUpdatedTtc: () => ({}),
getUpdatedTtc: vi.fn(() => ({ [Date.now()]: 123 })),
}));
vi.mock("@/lib/utils", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
getShuffledChoicesIds: (choices: any[], _?: string) => choices.map((c) => c.id),
getShuffledChoicesIds: vi.fn((_, length) => Array.from({ length }, (_, i) => i.toString())),
}));
describe("RankingQuestion", () => {
describe("RankingQuestion - New Error Handling", () => {
beforeEach(() => {
vi.stubGlobal("performance", {
now: vi.fn(() => 1000),
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.restoreAllMocks();
});
const mockQuestion: TSurveyRankingQuestion = {
id: "q1",
id: "ranking-question-1",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Rank these items" },
subheader: { default: "Please rank all items" },
required: true,
choices: [
{ id: "c1", label: { default: "Item 1" } },
{ id: "c2", label: { default: "Item 2" } },
{ id: "c3", label: { default: "Item 3" } },
],
buttonLabel: { default: "Next" },
backButtonLabel: { default: "Back" },
choices: [
{ id: "choice1", label: { default: "Choice 1" } },
{ id: "choice2", label: { default: "Choice 2" } },
{ id: "choice3", label: { default: "Choice 3" } },
],
shuffleOption: "none",
};
const mockOptionalQuestion: TSurveyRankingQuestion = {
...mockQuestion,
required: false,
};
const defaultProps = {
question: mockQuestion,
value: [] as string[],
value: [],
onChange: vi.fn(),
onSubmit: vi.fn(),
onBack: vi.fn(),
isFirstQuestion: false,
isLastQuestion: false,
languageCode: "en",
ttc: {} as TResponseTtc,
ttc: {},
setTtc: vi.fn(),
autoFocusEnabled: false,
currentQuestionId: "q1",
autoFocusEnabled: true,
currentQuestionId: "ranking-question-1",
isBackButtonHidden: false,
};
test("renders correctly with all elements", () => {
test("shows error message when required question has incomplete ranking", async () => {
render(<RankingQuestion {...defaultProps} />);
expect(screen.getByTestId("headline")).toBeInTheDocument();
expect(screen.getByTestId("subheader")).toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.getByTestId("back-button")).toBeInTheDocument();
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("Item 3")).toBeInTheDocument();
});
test("renders media when available", () => {
const questionWithMedia = {
...mockQuestion,
imageUrl: "https://example.com/image.jpg",
};
render(<RankingQuestion {...defaultProps} question={questionWithMedia} />);
expect(screen.getByTestId("question-media")).toBeInTheDocument();
});
test("doesn't show back button when isFirstQuestion is true", () => {
render(<RankingQuestion {...defaultProps} isFirstQuestion={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("doesn't show back button when isBackButtonHidden is true", () => {
render(<RankingQuestion {...defaultProps} isBackButtonHidden={true} />);
expect(screen.queryByTestId("back-button")).not.toBeInTheDocument();
});
test("clicking on item adds it to the sorted list", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} />);
const item1 = screen.getByText("Item 1").closest("div");
await user.click(item1!);
const itemElements = screen
.getAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(itemElements.length).toBeGreaterThan(0);
});
test("clicking on a sorted item removes it from the sorted list", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} value={["c1"]} />);
// First verify the item is in the sorted list
const sortedItems = screen
.getAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(sortedItems.length).toBeGreaterThan(0);
// Click the item to unselect it
const item1 = screen.getByText("Item 1").closest("div");
await user.click(item1!);
// The move buttons should be gone
const moveButtons = screen
.queryAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(moveButtons.length).toBe(0);
});
test("moving an item up in the list", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} value={["c1", "c2"]} />);
const upButtons = screen.getAllByRole("button").filter((btn) => btn.innerHTML.includes("chevron-up"));
// The first item's up button should be disabled
expect(upButtons[0]).toBeDisabled();
// The second item's up button should work
expect(upButtons[1]).not.toBeDisabled();
await user.click(upButtons[1]);
});
test("moving an item down in the list", async () => {
// For this test, we'll just verify the component renders correctly with ranked items
render(<RankingQuestion {...defaultProps} value={["c1", "c2"]} />);
// Verify both items are rendered
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
// Verify there are some move buttons present
const buttons = screen.getAllByRole("button");
const moveButtons = buttons.filter(
(btn) =>
btn.innerHTML && (btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"))
);
// Just make sure we have some move buttons rendered
expect(moveButtons.length).toBeGreaterThan(0);
});
test("submits form with complete ranking", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} value={["c1", "c2", "c3"]} />);
const submitButton = screen.getByTestId("submit-button");
await user.click(submitButton);
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText("Please rank all items before submitting.")).toBeInTheDocument();
});
});
test("allows submission with empty ranking for optional question", () => {
render(<RankingQuestion {...defaultProps} question={mockOptionalQuestion} />);
const submitButton = screen.getByTestId("submit-button");
fireEvent.click(submitButton);
expect(defaultProps.onSubmit).toHaveBeenCalled();
expect(screen.queryByText("Please rank all items before submitting.")).not.toBeInTheDocument();
});
test("clicking back button calls onBack", async () => {
const user = userEvent.setup();
render(<RankingQuestion {...defaultProps} />);
const backButton = screen.getByTestId("back-button");
await user.click(backButton);
expect(defaultProps.onChange).toHaveBeenCalled();
expect(defaultProps.onBack).toHaveBeenCalled();
});
test("allows incomplete ranking if not required", async () => {
const user = userEvent.setup();
const nonRequiredQuestion = {
...mockQuestion,
required: false,
};
render(<RankingQuestion {...defaultProps} question={nonRequiredQuestion} value={[]} />);
const submitButton = screen.getByTestId("submit-button");
await user.click(submitButton);
expect(defaultProps.onSubmit).toHaveBeenCalled();
});
test("handles keyboard navigation", () => {
render(<RankingQuestion {...defaultProps} />);
const item = screen.getByText("Item 1").closest("div");
fireEvent.keyDown(item!, { key: " " }); // Space key
const moveButtons = screen
.getAllByRole("button")
.filter((btn) => btn.innerHTML.includes("chevron-up") || btn.innerHTML.includes("chevron-down"));
expect(moveButtons.length).toBeGreaterThan(0);
});
test("applies shuffle option correctly", () => {
const shuffledQuestion = {
...mockQuestion,
shuffleOption: "all",
} as TSurveyRankingQuestion;
render(<RankingQuestion {...defaultProps} question={shuffledQuestion} />);
expect(screen.getByText("Item 1")).toBeInTheDocument();
expect(screen.getByText("Item 2")).toBeInTheDocument();
expect(screen.getByText("Item 3")).toBeInTheDocument();
});
});

View File

@@ -3,12 +3,15 @@ import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
import {
ScrollableContainer,
type ScrollableContainerHandle,
} from "@/components/wrappers/scrollable-container";
import { getLocalizedValue } from "@/lib/i18n";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { cn, getShuffledChoicesIds } from "@/lib/utils";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback, useMemo, useState } from "preact/hooks";
import { useCallback, useMemo, useRef, useState } from "preact/hooks";
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
import type {
TSurveyQuestionChoice,
@@ -58,6 +61,7 @@ export function RankingQuestion({
}, [question.shuffleOption, question.choices.length]);
const [parent] = useAutoAnimate();
const scrollableRef = useRef<ScrollableContainerHandle>(null);
const [error, setError] = useState<string | null>(null);
const isMediaAvailable = question.imageUrl || question.videoUrl;
@@ -119,6 +123,13 @@ export function RankingQuestion({
if (hasIncompleteRanking) {
setError("Please rank all items before submitting.");
// Scroll to bottom to show the error message
setTimeout(() => {
if (scrollableRef.current?.scrollToBottom) {
scrollableRef.current.scrollToBottom();
}
}, 100);
return;
}
@@ -144,7 +155,7 @@ export function RankingQuestion({
return (
<form onSubmit={handleSubmit} className="fb-w-full">
<ScrollableContainer>
<ScrollableContainer ref={scrollableRef}>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />

View File

@@ -1,216 +1,94 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact";
import { cleanup, render } from "@testing-library/preact";
import { createRef } from "preact";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ScrollableContainer } from "./scrollable-container";
import { ScrollableContainer, type ScrollableContainerHandle } from "./scrollable-container";
// Mock cn utility
vi.mock("@/lib/utils", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
}));
describe("ScrollableContainer", () => {
describe("ScrollableContainer - New Ref Functionality", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks(); // Restore all spies
});
// Helper to set scroll properties on an element
const setScrollProps = (
element: HTMLElement,
scrollHeight: number,
clientHeight: number,
scrollTop: number
) => {
Object.defineProperty(element, "scrollHeight", { configurable: true, value: scrollHeight });
Object.defineProperty(element, "clientHeight", { configurable: true, value: clientHeight });
Object.defineProperty(element, "scrollTop", { configurable: true, value: scrollTop });
};
test("ref exposes scrollToBottom method", () => {
const ref = createRef<ScrollableContainerHandle>();
test("renders children correctly", () => {
render(
<ScrollableContainer>
<div>Test Content</div>
</ScrollableContainer>
);
expect(screen.getByText("Test Content")).toBeInTheDocument();
});
test("initial state with short content (not scrollable)", async () => {
const { container } = render(
<ScrollableContainer>
<div style={{ height: "50px" }}>Short Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toBeInTheDocument();
if (scrollableDiv) {
setScrollProps(scrollableDiv, 50, 100, 0); // Content shorter than container
fireEvent.scroll(scrollableDiv); // Trigger checkScroll
}
await waitFor(() => {
// isAtTop = true, isAtBottom = true
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // No top gradient
expect(container.querySelector(".fb-bg-gradient-to-t")).toBeNull(); // No bottom gradient
});
});
test("initial state with long content (scrollable at top)", async () => {
const { container } = render(
<ScrollableContainer>
<div style={{ height: "200px" }}>Long Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toBeInTheDocument();
if (scrollableDiv) {
setScrollProps(scrollableDiv, 200, 100, 0); // Content longer than container, at top
fireEvent.scroll(scrollableDiv); // Trigger checkScroll
}
await waitFor(() => {
// isAtTop = true, isAtBottom = false
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // No top gradient
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
});
});
test("scrolling behavior updates gradients", async () => {
const { container } = render(
<ScrollableContainer>
<div style={{ height: "300px" }}>Scrollable Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toBeInTheDocument();
if (!scrollableDiv) throw new Error("Scrollable div not found");
// Initial: At top
setScrollProps(scrollableDiv, 300, 100, 0);
fireEvent.scroll(scrollableDiv); // Trigger checkScroll for initial state
await waitFor(() => {
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull();
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull();
});
// Scroll to middle
setScrollProps(scrollableDiv, 300, 100, 50);
fireEvent.scroll(scrollableDiv);
await waitFor(() => {
// isAtTop = false, isAtBottom = false
expect(container.querySelector(".fb-bg-gradient-to-b")).not.toBeNull(); // Top gradient visible
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
});
// Scroll to bottom
setScrollProps(scrollableDiv, 300, 100, 200); // scrollTop + clientHeight = scrollHeight
fireEvent.scroll(scrollableDiv);
await waitFor(() => {
// isAtTop = false, isAtBottom = true
expect(container.querySelector(".fb-bg-gradient-to-b")).not.toBeNull(); // Top gradient visible
expect(container.querySelector(".fb-bg-gradient-to-t")).toBeNull(); // No bottom gradient
});
// Scroll back to top
setScrollProps(scrollableDiv, 300, 100, 0);
fireEvent.scroll(scrollableDiv);
await waitFor(() => {
// isAtTop = true, isAtBottom = false
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // No top gradient
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
});
});
test("uses 60dvh maxHeight by default when not in survey preview", () => {
vi.spyOn(document, "getElementById").mockReturnValue(null);
const { container } = render(
<ScrollableContainer>
<ScrollableContainer ref={ref}>
<div>Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toHaveStyle({ maxHeight: "60dvh" });
expect(ref.current).toEqual({
scrollToBottom: expect.any(Function),
});
});
test("uses 42dvh maxHeight when isSurveyPreview is true", () => {
vi.spyOn(document, "getElementById").mockReturnValue(document.createElement("div")); // Simulate survey-preview element exists
test("scrollToBottom functionality works correctly", () => {
const ref = createRef<ScrollableContainerHandle>();
const { container } = render(
<ScrollableContainer>
<div>Content</div>
<ScrollableContainer ref={ref}>
<div style={{ height: "200px" }}>Tall Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toHaveStyle({ maxHeight: "42dvh" });
});
test("cleans up scroll event listener on unmount", () => {
const { unmount, container } = render(
<ScrollableContainer>
<div>Test Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toBeInTheDocument();
if (scrollableDiv) {
const removeEventListenerSpy = vi.spyOn(scrollableDiv, "removeEventListener");
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith("scroll", expect.any(Function));
} else {
throw new Error("Scrollable div not found for unmount test");
// Mock scroll properties
Object.defineProperty(scrollableDiv, "scrollHeight", {
value: 200,
configurable: true,
});
let currentScrollTop = 0;
Object.defineProperty(scrollableDiv, "scrollTop", {
get: () => currentScrollTop,
set: (value) => {
currentScrollTop = value;
},
configurable: true,
});
// Call scrollToBottom
ref.current?.scrollToBottom();
// Check that scrollTop was set to scrollHeight
expect(currentScrollTop).toBe(200);
}
});
test("updates scroll state when children prop changes causing scrollHeight change", async () => {
const { rerender, container } = render(
<ScrollableContainer>
<div style={{ height: "50px" }}>Short Content</div>
test("scrollToBottom handles null containerRef gracefully", () => {
const ref = createRef<ScrollableContainerHandle>();
render(
<ScrollableContainer ref={ref}>
<div>Content</div>
</ScrollableContainer>
);
const scrollableDiv = container.querySelector<HTMLElement>(".fb-overflow-auto");
expect(scrollableDiv).toBeInTheDocument();
if (!scrollableDiv) throw new Error("Scrollable div not found");
// Initial: Short content
setScrollProps(scrollableDiv, 50, 100, 0);
fireEvent.scroll(scrollableDiv); // Trigger checkScroll
await waitFor(() => {
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull();
expect(container.querySelector(".fb-bg-gradient-to-t")).toBeNull();
});
// Rerender with long content
rerender(
<ScrollableContainer>
<div style={{ height: "200px" }}>Long Content</div>
</ScrollableContainer>
);
// Simulate new scrollHeight due to new children, and assume it's at the top
setScrollProps(scrollableDiv, 200, 100, 0);
fireEvent.scroll(scrollableDiv); // Trigger checkScroll with new props after rerender
await waitFor(() => {
expect(container.querySelector(".fb-bg-gradient-to-b")).toBeNull(); // Still at top
expect(container.querySelector(".fb-bg-gradient-to-t")).not.toBeNull(); // Bottom gradient visible
});
// Should not throw when called
expect(() => ref.current?.scrollToBottom()).not.toThrow();
});
test("handles containerRef.current being null initially in checkScroll", () => {
// This test ensures that the null check for containerRef.current prevents errors.
// It's hard to directly test the early return without altering the component's timing.
// We rely on the fact that if it were to error, other tests might fail or it would show in coverage.
// Forcing containerRef.current to be null when checkScroll is called is tricky.
// However, the component's structure with useEffect should mean checkScroll is called after render.
// We can ensure no error is thrown during render.
expect(() => {
render(
<ScrollableContainer>
<div>Content</div>
</ScrollableContainer>
);
}).not.toThrow();
test("handles function ref correctly", () => {
let refValue: ScrollableContainerHandle | null = null;
const functionRef = (ref: ScrollableContainerHandle | null) => {
refValue = ref;
};
render(
<ScrollableContainer ref={functionRef}>
<div>Content</div>
</ScrollableContainer>
);
expect(refValue).toEqual({
scrollToBottom: expect.any(Function),
});
});
});

View File

@@ -1,61 +1,79 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "preact/hooks";
import type { JSX } from "react";
import type { JSX, Ref } from "preact";
import { forwardRef } from "preact/compat";
import { useEffect, useImperativeHandle, useRef, useState } from "preact/hooks";
interface ScrollableContainerProps {
children: JSX.Element;
}
export function ScrollableContainer({ children }: Readonly<ScrollableContainerProps>) {
const [isAtBottom, setIsAtBottom] = useState(false);
const [isAtTop, setIsAtTop] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
const checkScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
setIsAtTop(scrollTop === 0);
};
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const handleScroll = () => {
checkScroll();
};
element.addEventListener("scroll", handleScroll);
return () => {
element.removeEventListener("scroll", handleScroll);
};
}, []);
useEffect(() => {
checkScroll();
}, [children]);
return (
<div className="fb-relative">
{!isAtTop && (
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-6 fb-bg-gradient-to-b fb-to-transparent" />
)}
<div
ref={containerRef}
style={{
scrollbarGutter: "stable both-edges",
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
}}
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
{children}
</div>
{!isAtBottom && (
<div className="fb-from-survey-bg fb-absolute -fb-bottom-2 fb-left-0 fb-right-2 fb-h-8 fb-bg-gradient-to-t fb-to-transparent" />
)}
</div>
);
export interface ScrollableContainerHandle {
scrollToBottom: () => void;
}
export const ScrollableContainer = forwardRef<ScrollableContainerHandle, ScrollableContainerProps>(
({ children }: ScrollableContainerProps, ref: Ref<ScrollableContainerHandle>) => {
const [isAtBottom, setIsAtBottom] = useState(false);
const [isAtTop, setIsAtTop] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const isSurveyPreview = Boolean(document.getElementById("survey-preview"));
const checkScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
setIsAtTop(scrollTop === 0);
};
const scrollToBottom = () => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
};
// Expose only the `scrollToBottom` method to parent components via the forwarded ref
useImperativeHandle(ref, () => ({
scrollToBottom,
}));
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const handleScroll = () => {
checkScroll();
};
element.addEventListener("scroll", handleScroll);
return () => {
element.removeEventListener("scroll", handleScroll);
};
}, []);
useEffect(() => {
checkScroll();
}, [children]);
return (
<div className="fb-relative">
{!isAtTop && (
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-6 fb-bg-gradient-to-b fb-to-transparent" />
)}
<div
ref={containerRef}
style={{
scrollbarGutter: "stable both-edges",
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
}}
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
{children}
</div>
{!isAtBottom && (
<div className="fb-from-survey-bg fb-absolute -fb-bottom-2 fb-left-0 fb-right-2 fb-h-8 fb-bg-gradient-to-t fb-to-transparent" />
)}
</div>
);
}
);