diff --git a/ui/app/(prowler)/lighthouse/_components/chat/composer.integration.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/composer.integration.test.tsx new file mode 100644 index 0000000000..040b78a5b9 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/composer.integration.test.tsx @@ -0,0 +1,168 @@ +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; + +import { render } from "@/__tests__/render-browser"; +import { LighthouseCurrentContextBadge } from "@/components/lighthouse/context-chip"; +import { getSkillById } from "@/lib/lighthouse/skills/registry"; +import { + SIDE_PANEL_DEFAULT_WIDTH, + SIDE_PANEL_MIN_WIDTH, +} from "@/lib/ui-layout"; +import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; +import { LIGHTHOUSE_SKILL_ID } from "@/types/lighthouse-skills"; + +import { ChatComposerPanel } from "./composer"; +import { CurrentModelDisplay } from "./current-model-display"; +import { ModelSelect } from "./model-select"; +import { SkillComposerPill } from "./skill-composer-pill"; + +const CONNECTION_GUIDE = getSkillById( + LIGHTHOUSE_SKILL_ID.PROVIDER_CONNECTION_GUIDE, +)!; + +const PROVIDERS_CONTEXT: LighthouseContextEnvelope = { + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "page", + id: "providers", + source: "automatic", + scopeKey: "providers:/providers", + label: "Providers", + path: "/providers", + filters: {}, + }, + ], +}; + +// The two model controls the chat view can put in the footer. +const MODEL_CONTROLS = [ + { + name: "the current model", + control: ( + + ), + locate: () => page.getByLabelText("Current model: OpenAI GPT-5.2"), + // The part of the control that must stay readable. + readableText: () => page.getByText("OpenAI", { exact: true }), + }, + { + name: "the model picker", + control: ( + {}} + groups={[ + { + heading: "OpenAI", + options: [ + { value: "openai:gpt-5.2-codex-max", label: "GPT-5.2 Codex Max" }, + ], + }, + ]} + isSaving={false} + /> + ), + locate: () => page.getByRole("combobox", { name: "Model" }), + readableText: () => page.getByText("GPT-5.2 Codex Max"), + }, +]; + +const PANEL_WIDTHS = [ + { panel: "the narrowest", width: SIDE_PANEL_MIN_WIDTH }, + { panel: "the default", width: SIDE_PANEL_DEFAULT_WIDTH }, +]; + +interface ComposerInPanelProps { + width: number; + modelSelector: ReactNode; +} + +function ComposerInPanel({ width, modelSelector }: ComposerInPanelProps) { + return ( +
+ {}} + onDismissFeedback={() => {}} + canSend + input="" + isStreaming + modelSelector={modelSelector} + contextControl={ + <> + + + + } + selectedConfigurationConnected + onInputChange={() => {}} + onSubmit={(event) => event.preventDefault()} + onSubmitText={async () => {}} + /> +
+ ); +} + +function overlaps(a: DOMRect, b: DOMRect) { + return ( + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom + ); +} + +function isInside(inner: DOMRect, outer: DOMRect) { + return inner.left >= outer.left && inner.right <= outer.right; +} + +describe("Lighthouse composer footer", () => { + it.each( + MODEL_CONTROLS.flatMap((model) => + PANEL_WIDTHS.map((panel) => ({ ...model, ...panel })), + ), + )( + "keeps $name, the running skill and the context apart in $panel side panel", + async ({ control, locate, readableText, width }) => { + // Given / When: a skill runs while the chat sits in the side panel + render(); + const model = locate(); + await expect.element(model).toBeVisible(); + + // Then: no control covers another or leaves the composer + const composerBox = page + .getByLabelText("Message") + .element() + .closest("form")! + .getBoundingClientRect(); + const modelBox = model.element().getBoundingClientRect(); + const skillBox = page + .getByText("Skill · Connection Guide") + .element() + .closest('[role="status"]')! + .getBoundingClientRect(); + const contextBox = page + .getByLabelText("Providers context") + .element() + .getBoundingClientRect(); + + expect(overlaps(modelBox, skillBox)).toBe(false); + expect(overlaps(modelBox, contextBox)).toBe(false); + expect(overlaps(skillBox, contextBox)).toBe(false); + for (const box of [modelBox, skillBox, contextBox]) { + expect(isInside(box, composerBox)).toBe(true); + } + // And the model stays readable: whole, and inside its own control + const text = readableText().element() as HTMLElement; + expect(text.scrollWidth).toBeLessThanOrEqual(text.clientWidth); + expect(text.getBoundingClientRect().right).toBeLessThanOrEqual( + modelBox.right, + ); + }, + ); +}); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx index 78b8f09d4d..ceb52943b5 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/composer.tsx @@ -167,8 +167,9 @@ function ChatComposer({ } }} /> -
-
+
+ {/* Chips wrap to a new line instead of squeezing the model control. */} +
{modelSelector} - {contextControl} + {contextControl && ( +
+ {contextControl} +
+ )}
{isStreaming ? (
+ + + {providerName} + + + {modelName} + +
+ ); +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx index 839949fa81..ea4c2f1ca0 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/lighthouse-v2-chat-view.tsx @@ -29,17 +29,13 @@ import { } from "@/app/(prowler)/lighthouse/_types"; import { LighthouseCurrentContextBadge } from "@/components/lighthouse/context-chip"; import { Card } from "@/components/shadcn"; -import { - Combobox, - type ComboboxGroup, -} from "@/components/shadcn/combobox/combobox"; +import { type ComboboxGroup } from "@/components/shadcn/combobox/combobox"; import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { useLighthouseCurrentContext } from "@/hooks/use-lighthouse-context"; import { useMountEffect } from "@/hooks/use-mount-effect"; -import { ProviderIcon } from "../config/provider-icon"; - import { ChatComposerPanel } from "./composer"; +import { CurrentModelDisplay } from "./current-model-display"; import { ChatEmptyState } from "./empty-state"; import { useLighthouseChatStore } from "./lighthouse-chat-store-provider"; import { @@ -47,6 +43,7 @@ import { type LighthouseFeedbackSurvey, } from "./lighthouse-feedback-survey"; import { MessageBubble } from "./message-bubble"; +import { ModelSelect } from "./model-select"; import { SkillComposerPill } from "./skill-composer-pill"; import { SkillRunProgress } from "./skill-run-progress"; import { StreamingAssistantMessage } from "./streaming-message"; @@ -183,19 +180,12 @@ export function LighthouseV2ChatView({ modelName={selectedModelLabel} /> ) : ( -
- -
+ ), selectedConfigurationConnected: selectedConfiguration?.connected === true, onInputChange: setInput, @@ -323,36 +313,6 @@ function SessionLoadingState() { ); } -interface CurrentModelDisplayProps { - provider: LighthouseV2ProviderType; - providerName: string; - modelName: string; -} - -function CurrentModelDisplay({ - provider, - providerName, - modelName, -}: CurrentModelDisplayProps) { - return ( -
- - - {providerName} - - - {modelName} - -
- ); -} - function buildModelSelectorGroups( connectedConfigurations: LighthouseV2Configuration[], modelsByProvider: Record< diff --git a/ui/app/(prowler)/lighthouse/_components/chat/model-select.tsx b/ui/app/(prowler)/lighthouse/_components/chat/model-select.tsx new file mode 100644 index 0000000000..ea598bc45a --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/model-select.tsx @@ -0,0 +1,35 @@ +import { + Combobox, + type ComboboxGroup, +} from "@/components/shadcn/combobox/combobox"; + +interface ModelSelectProps { + value: string; + onValueChange: (value: string) => void; + groups: ComboboxGroup[]; + isSaving: boolean; +} + +// Keeps a minimum width so the composer chips wrap instead of squeezing it. +export function ModelSelect({ + value, + onValueChange, + groups, + isSaving, +}: ModelSelectProps) { + return ( +
+ +
+ ); +} diff --git a/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.test.tsx b/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.test.tsx new file mode 100644 index 0000000000..5724973957 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; + +import { SkillMessageCard } from "./skill-message-card"; + +describe("SkillMessageCard", () => { + it("names the provider and wizard step a connection guide was asked for", () => { + // Given + const context: LighthouseContextEnvelope = { + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "provider", + id: "provider-wizard", + source: "focused", + scopeKey: "providers:wizard", + label: "Amazon Web Services · Link a Provider", + providerType: "aws", + }, + ], + }; + + // When + render( + , + ); + + // Then + expect(screen.getByText("Connection Guide")).toBeInTheDocument(); + expect( + screen.getByText("Amazon Web Services · Link a Provider"), + ).toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.tsx b/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.tsx index 6852bda8cc..a8981a951d 100644 --- a/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.tsx +++ b/ui/app/(prowler)/lighthouse/_components/chat/skill-message-card.tsx @@ -18,8 +18,11 @@ interface SkillMessageCardProps { export function SkillMessageCard({ skillRef, context }: SkillMessageCardProps) { const definition = getSkillById(skillRef.skillId); const Icon = definition?.icon ?? Sparkles; - const findingLabel = context?.items.find( - (item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.FINDING, + // The subject the skill ran on: a finding, or the provider being connected. + const subjectLabel = context?.items.find( + (item) => + item.kind === LIGHTHOUSE_CONTEXT_KIND.FINDING || + item.kind === LIGHTHOUSE_CONTEXT_KIND.PROVIDER, )?.label; return ( @@ -34,9 +37,9 @@ export function SkillMessageCard({ skillRef, context }: SkillMessageCardProps) { {skillRef.name} - {findingLabel && ( + {subjectLabel && ( - {findingLabel} + {subjectLabel} )}
diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx index 90b178d755..5f44b574aa 100644 --- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx +++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.test.tsx @@ -20,6 +20,7 @@ import { } from "@/lib/lighthouse/context/contributions"; import { useLighthouseContextStore } from "@/store/lighthouse-context/store"; import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils"; +import { useSidePanelStore } from "@/store/side-panel"; import type { LighthouseContextEnvelope } from "@/types/lighthouse-context"; import { @@ -112,6 +113,7 @@ describe("LighthousePanelChat", () => { resetPanelChatStoreForTests(); resetPanelChatConfigCacheForTests(); resetLighthouseContextStore(); + useSidePanelStore.setState({ modalHostCount: 0 }); getConfigurationsMock.mockResolvedValue({ data: configurations }); getSupportedProvidersMock.mockResolvedValue({ @@ -182,6 +184,30 @@ describe("LighthousePanelChat", () => { ).toHaveAttribute("href", "/lighthouse/settings"); }); + it("opens its links in a new tab while a dialog shares the screen with the panel", async () => { + // Given: the add-provider wizard is open beside the panel + getConfigurationsMock.mockResolvedValue({ + data: [{ ...configurations[0], connected: false }], + }); + useSidePanelStore.getState().registerModalHost(); + + // When + render( + <> + + + , + ); + + // Then: following a link never navigates the wizard away + expect( + await screen.findByRole("link", { name: "Connect an LLM provider" }), + ).toHaveAttribute("target", "_blank"); + expect( + screen.getByRole("link", { name: "Open Lighthouse AI full page" }), + ).toHaveAttribute("target", "_blank"); + }); + it("renders the chat composer and recent chats once ready", async () => { // Given getSessionsMock.mockResolvedValue({ diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx index 0cb7531b31..690b6f9080 100644 --- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx +++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-chat.tsx @@ -45,6 +45,7 @@ import { } from "../chat/lighthouse-v2-chat-view"; import { LighthousePanelChatSkeleton } from "./lighthouse-panel-chat-skeleton"; +import { usePanelLinkTarget } from "./use-panel-link-target"; const PANEL_CHAT_STATUS = { LOADING: "loading", @@ -301,6 +302,7 @@ function PanelChatError({ message, onRetry }: PanelChatErrorProps) { } function PanelChatConnectCta() { + const linkTarget = usePanelLinkTarget(); return (
@@ -314,7 +316,9 @@ function PanelChatConnectCta() {

); diff --git a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-header-actions.tsx b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-header-actions.tsx index 9f71449626..67cb3cb62b 100644 --- a/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-header-actions.tsx +++ b/ui/app/(prowler)/lighthouse/_components/panel/lighthouse-panel-header-actions.tsx @@ -19,6 +19,8 @@ import { import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes"; import { cn } from "@/lib/utils"; +import { usePanelLinkTarget } from "./use-panel-link-target"; + export function LighthousePanelHeaderActions() { const hasMessages = useSyncExternalStore( subscribePanelChatHasMessages, @@ -30,6 +32,7 @@ export function LighthousePanelHeaderActions() { getPanelChatActiveSessionId, () => null, ); + const linkTarget = usePanelLinkTarget(); const fullPageHref = activeSessionId ? `${LIGHTHOUSE_ROUTE.CHAT}?session=${encodeURIComponent(activeSessionId)}` : LIGHTHOUSE_ROUTE.CHAT; @@ -62,7 +65,11 @@ export function LighthousePanelHeaderActions() { diff --git a/ui/app/(prowler)/lighthouse/_components/panel/use-panel-link-target.ts b/ui/app/(prowler)/lighthouse/_components/panel/use-panel-link-target.ts new file mode 100644 index 0000000000..42e3fea335 --- /dev/null +++ b/ui/app/(prowler)/lighthouse/_components/panel/use-panel-link-target.ts @@ -0,0 +1,15 @@ +"use client"; + +import { selectHasModalHost, useSidePanelStore } from "@/store/side-panel"; + +const NEW_TAB_LINK_PROPS = { + target: "_blank", + rel: "noopener noreferrer", +} as const; + +// While a dialog shares the screen with the panel, panel links open in a new +// tab so following one never navigates that dialog away. +export function usePanelLinkTarget() { + const hasModalHost = useSidePanelStore(selectHasModalHost); + return hasModalHost ? NEW_TAB_LINK_PROPS : {}; +} diff --git a/ui/components/findings/table/lighthouse-skills-launch.tsx b/ui/components/findings/table/lighthouse-skills-launch.tsx index f46d7f6394..c83e43ced6 100644 --- a/ui/components/findings/table/lighthouse-skills-launch.tsx +++ b/ui/components/findings/table/lighthouse-skills-launch.tsx @@ -4,6 +4,7 @@ import { CornerDownRight, PencilLine } from "lucide-react"; import { useState, type KeyboardEvent } from "react"; import { LighthouseIcon } from "@/components/icons"; +import { usePanelSkillLaunch } from "@/components/lighthouse/use-panel-skill-launch"; import { ActionDropdown, ActionDropdownItem, @@ -13,7 +14,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@/components/shadcn/dropdown"; -import { getAllSkills } from "@/lib/lighthouse/skills/registry"; +import { getSkillsForSurface } from "@/lib/lighthouse/skills/registry"; import { cn } from "@/lib/utils"; import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; import { @@ -21,7 +22,10 @@ import { type LighthouseContextEnvelope, type LighthouseFindingContextItem, } from "@/types/lighthouse-context"; -import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; +import { + LIGHTHOUSE_SKILL_SURFACE, + type LighthouseSkillDefinition, +} from "@/types/lighthouse-skills"; // Launching from a table row bypasses the context-contribution store: the row // already knows its finding, so the launch carries a minimal one-item envelope. @@ -36,21 +40,12 @@ function buildRowEnvelope( } export function useLighthouseSkillLaunch() { - const openSidePanel = useSidePanelStore((state) => state.openPanel); + const launchPanelSkill = usePanelSkillLaunch(); return ( skill: LighthouseSkillDefinition, findingItem: LighthouseFindingContextItem, - ) => { - openSidePanel(SIDE_PANEL_TAB.AI_CHAT); - // Lazy import: the panel chat store pulls in the whole chat/server-action - // graph, which table columns must not load just to render a menu. Ordering - // is safe — the store queues launches until the panel exists. - void import("@/app/(prowler)/lighthouse/_lib/panel-chat-store").then( - ({ requestPanelSkillLaunch }) => - requestPanelSkillLaunch(skill, buildRowEnvelope(findingItem)), - ); - }; + ) => launchPanelSkill(skill, buildRowEnvelope(findingItem)); } // Free-text sibling of useLighthouseSkillLaunch: starts a fresh Lighthouse @@ -69,7 +64,7 @@ export function useLighthousePromptLaunch() { // The one skills-menu catalog split, shared by every surface: the first // enabled skill leads as RECOMMENDED, the rest follow in catalog order. -const MENU_SKILLS = getAllSkills(); +const MENU_SKILLS = getSkillsForSurface(LIGHTHOUSE_SKILL_SURFACE.FINDING); const RECOMMENDED_SKILL = MENU_SKILLS.find((skill) => skill.enabled); const REST_SKILLS = MENU_SKILLS.filter((skill) => skill !== RECOMMENDED_SKILL); diff --git a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx b/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx index 1b5eb28ea0..3adb1bef3e 100644 --- a/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx +++ b/ui/components/findings/table/resource-detail-drawer/lighthouse-skills-rail.tsx @@ -6,16 +6,21 @@ import { LighthouseSkillsMenuItems } from "@/components/findings/table/lighthous import { LighthouseIcon } from "@/components/icons"; import { ActionDropdown } from "@/components/shadcn/dropdown"; import { - getAllSkills, getLaunchableSkills, + getSkillsForSurface, } from "@/lib/lighthouse/skills/registry"; -import type { LighthouseSkillDefinition } from "@/types/lighthouse-skills"; +import { + LIGHTHOUSE_SKILL_SURFACE, + type LighthouseSkillDefinition, +} from "@/types/lighthouse-skills"; // Catalog is static, so the chip split is module-level: launchable skills // become direct-launch chips; +N counts what the rail hides (disabled skills // today). The menu itself is the app-wide shared skills menu. -const CHIP_SKILLS = getLaunchableSkills(); -const OVERFLOW_COUNT = getAllSkills().length - CHIP_SKILLS.length; +const CHIP_SKILLS = getLaunchableSkills(LIGHTHOUSE_SKILL_SURFACE.FINDING); +const OVERFLOW_COUNT = + getSkillsForSurface(LIGHTHOUSE_SKILL_SURFACE.FINDING).length - + CHIP_SKILLS.length; interface LighthouseSkillsRailProps { onLaunchSkill: (skill: LighthouseSkillDefinition) => void; diff --git a/ui/components/layout/app-sidebar/app-sidebar.tsx b/ui/components/layout/app-sidebar/app-sidebar.tsx index 6864cd64a9..96deb887c9 100644 --- a/ui/components/layout/app-sidebar/app-sidebar.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar.tsx @@ -1,8 +1,15 @@ import { AppSidebarContent } from "./app-sidebar-content"; -export function AppSidebar() { +interface AppSidebarProps { + inert?: boolean; +} + +export function AppSidebar({ inert }: AppSidebarProps) { return ( -
- + ); } diff --git a/ui/components/providers/wizard/wizard-stepper.tsx b/ui/components/providers/wizard/wizard-stepper.tsx index 5ef115eff5..27fa41436f 100644 --- a/ui/components/providers/wizard/wizard-stepper.tsx +++ b/ui/components/providers/wizard/wizard-stepper.tsx @@ -58,14 +58,24 @@ export const AWS_PROVIDER_WIZARD_STEPS: StepConfig[] = [ STEPS[3], ]; +/** The stepper row a wizard step lands on once its offset is applied. */ +export function getActiveStepIndex( + currentStep: number, + stepOffset: number, + stepCount: number, +): number { + return Math.max(0, Math.min(currentStep + stepOffset, stepCount - 1)); +} + export function WizardStepper({ currentStep, stepOffset = 0, steps = STEPS, }: WizardStepperProps) { - const activeVisualStep = Math.max( - 0, - Math.min(currentStep + stepOffset, steps.length - 1), + const activeVisualStep = getActiveStepIndex( + currentStep, + stepOffset, + steps.length, ); return ( diff --git a/ui/components/shadcn/button/button.tsx b/ui/components/shadcn/button/button.tsx index bc37c08bc9..9dbc61b6fb 100644 --- a/ui/components/shadcn/button/button.tsx +++ b/ui/components/shadcn/button/button.tsx @@ -23,6 +23,9 @@ const buttonVariants = cva( "border-2 border-button-aws-marketplace bg-transparent text-button-aws-marketplace font-semibold hover:bg-button-aws-marketplace/5 active:bg-button-aws-marketplace/10 focus-visible:ring-button-aws-marketplace", ghost: "border border-transparent text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50", + // Lighthouse AI entry points: the brand gradient with dark text. + lighthouse: + "border border-transparent bg-lighthouse text-black font-semibold shadow-sm hover:brightness-105 hover:shadow-md active:brightness-95 focus-visible:ring-border-lighthouse disabled:bg-none", link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent", // Chrome-free: no border/background, only the icon shows. Hover/active shift // the icon color instead of painting a box. Pair with an `icon*` size. diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index 2b4669190c..0cfc36ee17 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -49,12 +49,14 @@ function DialogContent({ className, children, showCloseButton = true, + container, ...props }: React.ComponentProps & { showCloseButton?: boolean; + container?: React.ComponentProps["container"]; }) { return ( - + LighthouseCalloutIcon + LighthouseCalloutBody +// (Title, Description) + LighthouseCalloutActions (LighthouseCalloutAction). +export function LighthouseCallout({ + className, + ...props +}: ComponentProps<"section">) { + return ( +
+ ); +} + +export function LighthouseCalloutIcon({ + className, + ...props +}: ComponentProps<"span">) { + return ( + + ); +} + +export function LighthouseCalloutBody({ + className, + ...props +}: ComponentProps<"div">) { + return ( +
+ ); +} + +export function LighthouseCalloutTitle({ + className, + ...props +}: ComponentProps<"p">) { + return ( +

+ ); +} + +export function LighthouseCalloutDescription({ + className, + ...props +}: ComponentProps<"div">) { + return ( +

+ ); +} + +// Holds the primary action plus any secondary ones (e.g. a docs link). +export function LighthouseCalloutActions({ + className, + ...props +}: ComponentProps<"div">) { + return ( +
+ ); +} + +export function LighthouseCalloutAction({ + children, + ...props +}: Omit, "variant">) { + return ( + + ); +} diff --git a/ui/components/shadcn/modal/modal.test.tsx b/ui/components/shadcn/modal/modal.test.tsx new file mode 100644 index 0000000000..befe9b5da2 --- /dev/null +++ b/ui/components/shadcn/modal/modal.test.tsx @@ -0,0 +1,210 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { Modal } from "./modal"; + +interface OpenerHarnessProps { + isNonModal: boolean; + onCloseAutoFocus?: (event: Event) => void; + // A field that takes focus as it mounts, so Radix skips its open autofocus. + hasAutoFocusField?: boolean; +} + +// A controlled Modal opened by a plain button, the way the app opens them. +function OpenerHarness({ + isNonModal, + onCloseAutoFocus, + hasAutoFocusField = false, +}: OpenerHarnessProps) { + const [open, setOpen] = useState(false); + const body = ; + return ( + <> + + + {isNonModal ? ( + + {body} + + ) : ( + + {body} + + )} + + ); +} + +describe("Modal focus on close", () => { + it.each([ + { mode: "modal", isNonModal: false, closeWith: "the close button" }, + { mode: "modal", isNonModal: false, closeWith: "Escape" }, + { mode: "non-modal", isNonModal: true, closeWith: "the close button" }, + { mode: "non-modal", isNonModal: true, closeWith: "Escape" }, + ])( + "returns focus to the button that opened the $mode dialog after closing with $closeWith", + async ({ isNonModal, closeWith }) => { + // Given + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Add provider" })); + await user.click(await screen.findByLabelText("Account ID")); + + // When + if (closeWith === "Escape") { + await user.keyboard("{Escape}"); + } else { + await user.click(screen.getByRole("button", { name: "Close" })); + } + + // Then + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Add provider" }), + ).toHaveFocus(), + ); + }, + ); + + it.each([ + { mode: "modal", isNonModal: false }, + { mode: "non-modal", isNonModal: true }, + ])( + "returns focus to the opener of a $mode dialog whose field takes focus on mount", + async ({ isNonModal }) => { + // Given + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Add provider" })); + await waitFor(() => + expect(screen.getByLabelText("Account ID")).toHaveFocus(), + ); + + // When + await user.click(screen.getByRole("button", { name: "Close" })); + + // Then + await waitFor(() => + expect( + screen.getByRole("button", { name: "Add provider" }), + ).toHaveFocus(), + ); + }, + ); + + it("keeps the focus target chosen by the consumer", async () => { + // Given: the consumer sends focus somewhere else on close + const user = userEvent.setup(); + render( + { + event.preventDefault(); + screen.getByRole("button", { name: "Elsewhere" }).focus(); + }} + />, + ); + await user.click(screen.getByRole("button", { name: "Add provider" })); + await user.click(await screen.findByLabelText("Account ID")); + + // When + await user.click(screen.getByRole("button", { name: "Close" })); + + // Then + await waitFor(() => + expect(screen.getByRole("button", { name: "Elsewhere" })).toHaveFocus(), + ); + }); +}); + +function getBackdrop(): HTMLElement { + const backdrop = document.querySelector( + '[data-slot="modal-backdrop"]', + ); + if (!backdrop) throw new Error("Modal backdrop not rendered"); + return backdrop; +} + +describe("Modal (non-modal)", () => { + it("stays open while the user works outside it", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + <> + + +

Wizard body

+
+ , + ); + await screen.findByText("Wizard body"); + + // When: the user clicks and types in a control outside the dialog + await user.click(screen.getByLabelText("Side panel composer")); + await user.keyboard("help"); + + // Then + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Side panel composer")).toHaveValue("help"); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("closes when the backdrop is clicked", async () => { + // Given + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + +

Wizard body

+
, + ); + await screen.findByText("Wizard body"); + + // When + await user.click(getBackdrop()); + + // Then + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("leaves the reserved right inset uncovered", async () => { + // Given / When + render( + +

Wizard body

+
, + ); + await screen.findByText("Wizard body"); + + // Then: the frame holding backdrop and dialog stops where the inset starts + const frame = document.querySelector('[data-slot="modal-frame"]'); + expect(frame).toContainElement(getBackdrop()); + expect(frame).toContainElement(screen.getByRole("dialog")); + expect(frame).toHaveStyle({ right: "480px" }); + }); +}); diff --git a/ui/components/shadcn/modal/modal.tsx b/ui/components/shadcn/modal/modal.tsx index 058cce3e7d..b5767b7983 100644 --- a/ui/components/shadcn/modal/modal.tsx +++ b/ui/components/shadcn/modal/modal.tsx @@ -1,10 +1,19 @@ -import { ReactNode } from "react"; +"use client"; + +import { + type FocusEvent, + type ReactNode, + type Ref, + useRef, + useState, +} from "react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, + DialogPortal, DialogTitle, } from "@/components/shadcn/dialog"; import { cn } from "@/lib/utils"; @@ -23,8 +32,9 @@ const SIZE_CLASSES = { type ModalSize = keyof typeof SIZE_CLASSES; const preventInitialAutoFocus = (event: Event) => event.preventDefault(); +const preventOutsideDismiss = (event: Event) => event.preventDefault(); -interface ModalProps { +interface ModalBaseProps { open: boolean; onOpenChange: (open: boolean) => void; title?: string; @@ -32,8 +42,10 @@ interface ModalProps { children: ReactNode; size?: ModalSize; className?: string; + ref?: Ref; onOpenAutoFocus?: (event: Event) => void; onCloseAutoFocus?: (event: Event) => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; /** * Cap the dialog at 90dvh and scroll overflowing content, instead of * letting it grow past the viewport. Opt-in per modal (e.g. for content @@ -43,6 +55,24 @@ interface ModalProps { scrollable?: boolean; } +interface ModalDefaultProps extends ModalBaseProps { + modal?: true; + insetRight?: never; + isInsetResizing?: never; +} + +// The page outside stays usable (e.g. a docked side panel): outside clicks and +// focus never dismiss it; only the backdrop, Escape and the close button do. +interface ModalNonModalProps extends ModalBaseProps { + modal: false; + // Pixels left uncovered at the right edge; the dialog centers in the rest. + insetRight?: number; + // Follow insetRight 1:1 (no easing) while it is being dragged. + isInsetResizing?: boolean; +} + +type ModalProps = ModalDefaultProps | ModalNonModalProps; + export const Modal = ({ open, onOpenChange, @@ -51,36 +81,115 @@ export const Modal = ({ children, size = "xl", className, + ref, onOpenAutoFocus = preventInitialAutoFocus, onCloseAutoFocus, + onEscapeKeyDown, scrollable = false, + modal = true, + insetRight, + isInsetResizing = false, }: ModalProps) => { + // Non-modal frame: the content portals into it, so it is the containing + // block (contain:layout traps the fixed dialog) and the container-query + // reference for the whole dialog. + const [frame, setFrame] = useState(null); + const isNonModal = !modal; + // Modal is always controlled, so Radix has no trigger to give focus back to. + const openerRef = useRef(null); + + const handleOpenAutoFocus = (event: Event) => { + openerRef.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + onOpenAutoFocus(event); + }; + + // A self-focusing field skips Radix open autofocus: take the opener it left. + const handleFocusCapture = (event: FocusEvent) => { + const previous = event.relatedTarget; + if ( + !openerRef.current && + previous instanceof HTMLElement && + !event.currentTarget.contains(previous) + ) { + openerRef.current = previous; + } + }; + + const handleCloseAutoFocus = (event: Event) => { + onCloseAutoFocus?.(event); + const opener = openerRef.current; + openerRef.current = null; + if (event.defaultPrevented) return; + event.preventDefault(); + opener?.focus({ preventScroll: true }); + }; + return ( - - - {title && ( - - {title} - {description && ( - - {description} - + + {isNonModal && ( + +
- )} - {children} - + style={{ right: insetRight ?? 0 }} + > + {/* Plain inner node: a ref through the portal's Slot would + detach and reattach on every render. */} +
+
+ + )} + {isNonModal && frame && ( + // Radix renders no overlay for non-modal dialogs: this is ours. + +
onOpenChange(false)} + /> + + )} + {(!isNonModal || frame) && ( + + {title && ( + + {title} + {description && ( + + {description} + + )} + + )} + {children} + + )}
); }; diff --git a/ui/components/side-panel/global-side-panel.tsx b/ui/components/side-panel/global-side-panel.tsx index 539cca7880..8acb94c24b 100644 --- a/ui/components/side-panel/global-side-panel.tsx +++ b/ui/components/side-panel/global-side-panel.tsx @@ -22,6 +22,7 @@ import { isLighthouseChatRoute } from "@/lib/lighthouse-routes"; import { clampSidePanelWidth, getSidePanelMaxWidth, + SIDE_PANEL_ELEMENT_ID, SIDE_PANEL_MIN_WIDTH, SIDE_PANEL_PUSH_MEDIA_QUERY, } from "@/lib/ui-layout"; @@ -115,6 +116,7 @@ export function GlobalSidePanel() { return ( state.isOpen); + return ( + +