fix(ui): prevent feedback widget overlaps (#12282)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Alan Buscaglia
2026-08-06 12:28:32 +02:00
committed by GitHub
co-authored by alejandrobailo
parent b684ad06f3
commit ff0ee666e3
6 changed files with 226 additions and 9 deletions
@@ -0,0 +1 @@
Feedback widget no longer obscures page and side-panel actions
@@ -45,6 +45,29 @@ describe("Button", () => {
);
});
it("supports circular icon buttons without changing the default shape", () => {
// Given / When
const { rerender } = render(
<Button shape="circle" size="icon-lg" aria-label="Give feedback">
<svg aria-hidden="true" />
</Button>,
);
// Then
expect(screen.getByRole("button", { name: "Give feedback" })).toHaveClass(
"rounded-full",
"size-10",
);
// When
rerender(<Button>Default shape</Button>);
// Then
expect(screen.getByRole("button", { name: "Default shape" })).toHaveClass(
"rounded-[8px]",
);
});
it("renders the bare variant chrome-free (no background or border)", () => {
render(
<Button variant="bare" size="icon-sm" aria-label="Toggle sidebar">
+8 -2
View File
@@ -5,7 +5,7 @@ import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-[8px] text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:bg-button-disabled disabled:text-text-neutral-tertiary outline-none focus-visible:ring-2 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
{
variants: {
variant: {
@@ -38,10 +38,15 @@ const buttonVariants = cva(
"link-xs": "text-xs",
"link-sm": "text-sm",
},
shape: {
default: "rounded-[8px]",
circle: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
shape: "default",
},
},
);
@@ -50,6 +55,7 @@ function Button({
className,
variant,
size,
shape,
asChild = false,
...props
}: ComponentProps<"button"> &
@@ -61,7 +67,7 @@ function Button({
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size, shape, className }))}
{...props}
/>
);
+151 -3
View File
@@ -1,8 +1,13 @@
import { render, screen, waitFor } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Survey } from "posthog-js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clampSidePanelWidth } from "@/lib/ui-layout";
type SurveyCallback = (surveys: Survey[]) => void;
const mocks = vi.hoisted(() => ({
@@ -12,6 +17,8 @@ const mocks = vi.hoisted(() => ({
onSurveysLoaded: vi.fn(),
capture: vi.fn(),
moduleLoaded: vi.fn(),
pathname: "/",
isPushViewport: false,
// Mirrors the singleton's `__loaded` flag: true when a PostHog instance
// already exists (as on Prowler Cloud, initialized in app/providers.tsx).
loaded: false,
@@ -21,6 +28,12 @@ vi.mock("@/lib/shared/env", () => ({ isCloud: mocks.isCloud }));
vi.mock("@/hooks/use-runtime-config", () => ({
useRuntimeConfig: mocks.useRuntimeConfig,
}));
vi.mock("@/hooks/use-media-query", () => ({
useMediaQuery: () => mocks.isPushViewport,
}));
vi.mock("next/navigation", () => ({
usePathname: () => mocks.pathname,
}));
vi.mock("posthog-js", () => {
mocks.moduleLoaded();
return {
@@ -36,6 +49,10 @@ vi.mock("posthog-js", () => {
});
const POSTHOG_KEY = "phc_key";
const GLOBAL_STYLES = readFileSync(
resolve(process.cwd(), "styles/globals.css"),
"utf8",
);
const SURVEY_FIXTURE = {
id: "survey-123",
@@ -77,6 +94,9 @@ describe("FeedbackSurvey", () => {
vi.resetModules();
vi.resetAllMocks();
mocks.loaded = false;
mocks.pathname = "/";
mocks.isPushViewport = false;
localStorage.clear();
mocks.isCloud.mockReturnValue(true);
mocks.useRuntimeConfig.mockReturnValue({
cloudEnabled: true,
@@ -90,9 +110,10 @@ describe("FeedbackSurvey", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
it("renders the feedback trigger when Cloud and the survey is available", async () => {
it("renders an accessible icon-only circular feedback trigger", async () => {
// When
await renderSurvey();
@@ -101,7 +122,134 @@ describe("FeedbackSurvey", () => {
name: "Give feedback",
});
expect(trigger).toBeVisible();
expect(trigger).toHaveTextContent("Feedback");
expect(trigger).toHaveAccessibleName("Give feedback");
expect(trigger).not.toHaveTextContent("Feedback");
expect(trigger.querySelector("svg")).toBeInTheDocument();
expect(trigger).toHaveClass("rounded-full", "size-10");
});
it("activates feedback clearance only on the semantic main page scroller", async () => {
// When
await renderSurvey();
// Then
const trigger = await screen.findByRole("button", {
name: "Give feedback",
});
expect(trigger).toHaveAttribute("data-feedback-survey-trigger");
expect(GLOBAL_STYLES).toMatch(
/body:has\(\[data-feedback-survey-trigger\]\)\s+main\[data-responsive-container\]\s*{\s*@apply pb-36;\s*}/,
);
expect(GLOBAL_STYLES).not.toMatch(
/body:has\(\[data-feedback-survey-trigger\]\)\s+\[data-responsive-container\]\s*{/,
);
});
it("keeps the normal right gutter above page content when the side panel is closed", async () => {
// Given
mocks.isPushViewport = true;
// When
await renderSurvey();
// Then
const trigger = await screen.findByRole("button", {
name: "Give feedback",
});
expect(trigger).toHaveClass("right-6", "z-50");
expect(trigger.style.right).toBe("");
});
it("moves left by the clamped panel width on push viewports", async () => {
// Given - persisted widths are consumed defensively, just like MainLayout.
const persistedWidth = 2000;
vi.stubGlobal("innerWidth", 1200);
mocks.isPushViewport = true;
const { useSidePanelStore } = await import("@/store/side-panel");
useSidePanelStore.setState({
isOpen: true,
width: persistedWidth,
});
// When
await renderSurvey();
// Then
const trigger = await screen.findByRole("button", {
name: "Give feedback",
});
await waitFor(() =>
expect(trigger).toHaveStyle({
right: `${clampSidePanelWidth(persistedWidth) + 24}px`,
}),
);
expect(trigger).toHaveClass("transition-[right,transform]", "duration-200");
});
it("keeps the normal gutter on Lighthouse when stale state says the absent panel is open", async () => {
// Given
mocks.pathname = "/lighthouse";
mocks.isPushViewport = true;
const { useSidePanelStore } = await import("@/store/side-panel");
useSidePanelStore.setState({ isOpen: true, width: 720 });
// When
await renderSurvey();
// Then
const trigger = await screen.findByRole("button", {
name: "Give feedback",
});
expect(trigger).toBeVisible();
expect(trigger.style.right).toBe("");
});
it("hides the feedback trigger when the side panel overlays on mobile", async () => {
// Given
mocks.isPushViewport = false;
const { useSidePanelStore } = await import("@/store/side-panel");
useSidePanelStore.setState({ isOpen: true, width: 720 });
// When
await renderSurvey();
// Then
await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled());
expect(
screen.queryByRole("button", { name: "Give feedback" }),
).not.toBeInTheDocument();
});
it("keeps the feedback form closed after the mobile side panel closes", async () => {
// Given
mocks.isPushViewport = false;
const user = userEvent.setup();
const { useSidePanelStore } = await import("@/store/side-panel");
useSidePanelStore.setState({ isOpen: false });
await renderSurvey();
await user.click(
await screen.findByRole("button", { name: "Give feedback" }),
);
expect(
await screen.findByRole("heading", { name: "What could we do better?" }),
).toBeVisible();
// When
act(() => useSidePanelStore.setState({ isOpen: true }));
await waitFor(() =>
expect(
screen.queryByRole("button", { name: "Give feedback" }),
).not.toBeInTheDocument(),
);
act(() => useSidePanelStore.setState({ isOpen: false }));
// Then
expect(
await screen.findByRole("button", { name: "Give feedback" }),
).toBeVisible();
expect(
screen.queryByRole("heading", { name: "What could we do better?" }),
).not.toBeInTheDocument();
});
it("ignores a same-name non-API survey and selects the API survey", async () => {
@@ -1,9 +1,10 @@
"use client";
import { MessageSquareText } from "lucide-react";
import { usePathname } from "next/navigation";
import posthogClient from "posthog-js";
import type { Survey } from "posthog-js";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import {
@@ -12,9 +13,19 @@ import {
PopoverTrigger,
} from "@/components/shadcn/popover";
import { Textarea } from "@/components/shadcn/textarea/textarea";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useMountEffect } from "@/hooks/use-mount-effect";
import { useStore } from "@/hooks/use-store";
import { isLighthouseChatRoute } from "@/lib/lighthouse-routes";
import {
clampSidePanelWidth,
SIDE_PANEL_PUSH_MEDIA_QUERY,
} from "@/lib/ui-layout";
import { cn } from "@/lib/utils";
import { useSidePanelStore } from "@/store/side-panel";
const SURVEY_NAME = "Prowler Feedback";
const FEEDBACK_GUTTER_PX = 24;
const SURVEY_EVENT = {
SHOWN: "survey shown",
@@ -31,10 +42,20 @@ export default function RuntimeFeedbackSurvey({
posthogKey,
posthogHost,
}: RuntimeFeedbackSurveyProps) {
const pathname = usePathname();
const [survey, setSurvey] = useState<Survey | null>(null);
const [open, setOpen] = useState(false);
const [response, setResponse] = useState("");
const [submitted, setSubmitted] = useState(false);
const sidePanelOpen = useStore(useSidePanelStore, (state) => state.isOpen);
const sidePanelWidth = useStore(useSidePanelStore, (state) => state.width);
const sidePanelResizing = useStore(
useSidePanelStore,
(state) => state.isResizing,
);
const isPushViewport = useMediaQuery(SIDE_PANEL_PUSH_MEDIA_QUERY);
const sidePanelVisible =
Boolean(sidePanelOpen) && !isLighthouseChatRoute(pathname);
useMountEffect(() => {
if (!posthogClient.__loaded) {
@@ -56,13 +77,22 @@ export default function RuntimeFeedbackSurvey({
});
});
useEffect(() => {
if (sidePanelVisible && !isPushViewport) setOpen(false);
}, [sidePanelVisible, isPushViewport]);
const question = survey?.questions?.[0];
if (!survey || question?.type !== "open") return null;
if (sidePanelVisible && !isPushViewport) return null;
const questionId = question.id ?? "";
const appearance = survey.appearance;
const trimmedResponse = response.trim();
const identity = { $survey_id: survey.id, $survey_name: survey.name };
const pushedRight =
sidePanelVisible && sidePanelWidth !== undefined && isPushViewport
? clampSidePanelWidth(sidePanelWidth) + FEEDBACK_GUTTER_PX
: undefined;
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
@@ -98,14 +128,19 @@ export default function RuntimeFeedbackSurvey({
<Button
type="button"
aria-label="Give feedback"
size="xl"
className="group fixed right-6 bottom-20 z-50 transition-all duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] motion-reduce:transform-none motion-reduce:transition-none"
data-feedback-survey-trigger
shape="circle"
size="icon-lg"
className={cn(
"group fixed right-6 bottom-20 z-50 transition-[right,transform] duration-200 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98] motion-reduce:transform-none motion-reduce:transition-none",
sidePanelVisible && sidePanelResizing && "transition-none",
)}
style={{ right: pushedRight }}
>
<MessageSquareText
aria-hidden="true"
className="transition-transform duration-200 group-hover:scale-110 group-hover:-rotate-6 motion-reduce:transform-none motion-reduce:transition-none"
/>
<span>Feedback</span>
</Button>
</PopoverTrigger>
<PopoverContent
+4
View File
@@ -656,6 +656,10 @@
display: none; /* Chrome, Safari, Opera */
}
body:has([data-feedback-survey-trigger]) main[data-responsive-container] {
@apply pb-36;
}
/* Minimal scrollbar styles
*
* The descendant selectors target `.cm-scroller` so that CodeMirror