diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx index c4b25be94..d5551f2ac 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx @@ -93,43 +93,45 @@ export function OnboardingPage(props: OnboardingPageProps) {
-
+
{props.onBack != null && ( )} - {props.steps.map((step, index) => { - const isComplete = index < currentIndex; - const isCurrent = index === currentIndex; - const isClickable = isComplete && !props.disabled && props.onStepClick != null; + + {props.steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + const isClickable = isComplete && !props.disabled && props.onStepClick != null; - return ( -
diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx index 7bf965c55..29a524d1d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx @@ -27,7 +27,7 @@ import { PlusCircleIcon } from "@phosphor-icons/react"; import { type AdminOwnedProject } from "@hexclave/next"; import { runAsynchronouslyWithAlert, wait } from "@hexclave/shared/dist/utils/promises"; import { useSearchParams } from "next/navigation"; -import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { type FormEvent, type KeyboardEvent, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ProjectOnboardingWizard } from "./project-onboarding-wizard"; import { @@ -113,6 +113,87 @@ function PageClientInner() { router.replace(query.length > 0 ? `/new-project?${query}` : "/new-project"); }, [router, searchParams]); + const createProject = useCallback(async () => { + if (!beginPendingAction(creatingProjectRef, setCreatingProject)) { + return; + } + + try { + const trimmedProjectName = projectName.trim(); + if (trimmedProjectName.length === 0) { + throw new Error("Project name is required."); + } + + const firstTeam = teams.at(0); + const teamId = selectedTeamId ?? user.selectedTeam?.id ?? firstTeam?.id; + if (teamId === undefined) { + throw new Error("Select a team before creating the project."); + } + + const newProject = await user.createProject({ + displayName: trimmedProjectName, + teamId, + onboardingStatus: "config_choice", + }); + + setProjectStatuses((previous) => { + const next = new Map(previous); + next.set(newProject.id, "config_choice"); + return next; + }); + setProjectOnboardingStates((previous) => { + const next = new Map(previous); + next.set(newProject.id, null); + return next; + }); + + if (redirectToNeonConfirmWith != null) { + const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith); + confirmSearchParams.set("default_selected_project_id", newProject.id); + router.push(`/integrations/neon/confirm?${confirmSearchParams.toString()}`); + await wait(2000); + return; + } + + if (redirectToConfirmWith != null) { + const confirmSearchParams = new URLSearchParams(redirectToConfirmWith); + confirmSearchParams.set("default_selected_project_id", newProject.id); + router.push(`/integrations/custom/confirm?${confirmSearchParams.toString()}`); + await wait(2000); + return; + } + + updateSearchParams({ + project_id: newProject.id, + mode: null, + }); + } finally { + endPendingAction(creatingProjectRef, setCreatingProject); + } + }, [projectName, redirectToConfirmWith, redirectToNeonConfirmWith, router, selectedTeamId, teams, updateSearchParams, user]); + + const handleCreateProjectSubmit = useCallback((event: FormEvent) => { + event.preventDefault(); + if (!hasProjectName || creatingProject) { + return; + } + + runAsynchronouslyWithAlert(createProject()); + }, [createProject, creatingProject, hasProjectName]); + + const handleProjectNameKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) { + return; + } + + event.preventDefault(); + if (!hasProjectName || creatingProject) { + return; + } + + runAsynchronouslyWithAlert(createProject()); + }, [createProject, creatingProject, hasProjectName]); + const selectedProject = useMemo(() => { if (selectedProjectId == null) { return null; @@ -278,108 +359,52 @@ function PageClientInner() { -
-
- - setProjectName(event.target.value)} - placeholder="My Project" - /> -
- -
- -
- ({ value: team.id, label: team.displayName }))} +
+
+
+ + setProjectName(event.target.value)} + onKeyDown={handleProjectNameKeyDown} + placeholder="My Project" /> - setIsCreateTeamOpen(true)} className="rounded-xl sm:min-w-[152px]"> - - Create Team - +
+ +
+ +
+ ({ value: team.id, label: team.displayName }))} + /> + setIsCreateTeamOpen(true)} className="rounded-xl sm:min-w-[152px]"> + + Create Team + +
-
- - router.push("/projects")} disabled={creatingProject}> - Cancel - - { - if (!beginPendingAction(creatingProjectRef, setCreatingProject)) { - return; - } - - return runAsynchronouslyWithAlert(async () => { - const trimmedProjectName = projectName.trim(); - if (trimmedProjectName.length === 0) { - throw new Error("Project name is required."); - } - - const firstTeam = teams.at(0); - const teamId = selectedTeamId ?? user.selectedTeam?.id ?? firstTeam?.id; - if (teamId === undefined) { - throw new Error("Select a team before creating the project."); - } - - try { - const newProject = await user.createProject({ - displayName: trimmedProjectName, - teamId, - onboardingStatus: "config_choice", - }); - - setProjectStatuses((previous) => { - const next = new Map(previous); - next.set(newProject.id, "config_choice"); - return next; - }); - setProjectOnboardingStates((previous) => { - const next = new Map(previous); - next.set(newProject.id, null); - return next; - }); - - if (redirectToNeonConfirmWith != null) { - const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith); - confirmSearchParams.set("default_selected_project_id", newProject.id); - router.push(`/integrations/neon/confirm?${confirmSearchParams.toString()}`); - await wait(2000); - return; - } - - if (redirectToConfirmWith != null) { - const confirmSearchParams = new URLSearchParams(redirectToConfirmWith); - confirmSearchParams.set("default_selected_project_id", newProject.id); - router.push(`/integrations/custom/confirm?${confirmSearchParams.toString()}`); - await wait(2000); - return; - } - - updateSearchParams({ - project_id: newProject.id, - mode: null, - }); - } finally { - endPendingAction(creatingProjectRef, setCreatingProject); - } - }); - }} - > - Create Project - - + + router.push("/projects")} disabled={creatingProject}> + Cancel + + + Create Project + + + diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx index 3fbae8abc..a586a0dc6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx @@ -1,6 +1,9 @@ // @vitest-environment jsdom import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react"; +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; @@ -173,6 +176,19 @@ function createDeferred() { } describe("ProjectOnboardingWizard", () => { + it("keeps the hosted auth preview interactive", () => { + const testDir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join(testDir, "project-onboarding-wizard.tsx"), "utf-8"); + + const previewBlockMatch = source.match(/(<[^>]*HostedAuthMethodPreview[\s\S]*?\/>[\s\S]{0,300})/); + expect(previewBlockMatch).not.toBeNull(); + const previewBlock = previewBlockMatch![1]; + + expect(previewBlock).not.toContain("pointer-events-none"); + expect(previewBlock).not.toContain("inert"); + expect(previewBlock).not.toContain("bg-transparent"); + }); + it("keeps required apps when normalizing persisted onboarding state", () => { const normalizedState = normalizeProjectOnboardingState({ selected_config_choice: "create-new", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx index 42398e869..bd0059356 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx @@ -823,8 +823,7 @@ export function ProjectOnboardingWizard(props: { >
-
-
+
diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx index 2f0a955dd..e3ee335e4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client.test.tsx @@ -88,6 +88,21 @@ describe("new project page data loading", () => { }); }); +describe("new project creation dialog", () => { + it("uses native form submission for create-project keyboard access", () => { + const testDir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join( + testDir, + "page-client-parts/content.tsx", + ), "utf-8"); + + expect(source).toContain("
"); + expect(source).toContain("onKeyDown={handleProjectNameKeyDown}"); + expect(source).toContain('type="submit"'); + expect(source).toContain("Create Project"); + }); +}); + describe("OnboardingPage", () => { it("uses hover-exit-only transitions and accessible labels for progress dots", () => { render( @@ -114,6 +129,34 @@ describe("OnboardingPage", () => { expect(className).toContain("hover:transition-none"); expect(currentStepButton.getAttribute("aria-current")).toBe("step"); }); + + it("keeps the progress dots centered on a wider rail with the back arrow offset", () => { + render( + Continue} + > +
Step body
+
, + ); + + const backButtonClassName = screen.getByRole("button", { name: "Go back to previous step" }).getAttribute("class") ?? ""; + const progressRail = screen.getByRole("button", { name: "Apps" }).closest(".w-\\[150px\\]"); + const progressRailClassName = progressRail?.getAttribute("class") ?? ""; + + expect(backButtonClassName).toContain("inline-flex"); + expect(backButtonClassName).toContain("absolute"); + expect(backButtonClassName).toContain("left-0"); + expect(progressRailClassName).toContain("w-[150px]"); + expect(progressRailClassName).toContain("justify-center"); + }); }); describe("OnboardingAppCard", () => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.test.ts new file mode 100644 index 000000000..6dc6cbde9 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from "fs"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it } from "vitest"; + +describe("auth methods live preview", () => { + it("keeps the hosted auth preview interactive", () => { + const testDir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join(testDir, "page-client.tsx"), "utf-8"); + + const previewBlockMatch = source.match(/(<[^>]*HostedAuthMethodPreview[\s\S]*?\/>[\s\S]{0,300})/); + expect(previewBlockMatch).not.toBeNull(); + const previewBlock = previewBlockMatch![1]; + + expect(previewBlock).not.toContain("pointer-events-none"); + expect(previewBlock).not.toContain("inert"); + expect(previewBlock).not.toContain("bg-transparent"); + }); +}); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx index 8a7caaba6..8d4227dff 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/page-client.tsx @@ -769,8 +769,7 @@ function LivePreviewBody({
-
-
+
{ + cleanup(); +}); + +describe("HostedAuthMethodPreview", () => { + it("uses interactive hosted-style tabs and input surfaces", () => { + render(); + + const tabsList = screen.getByRole("tablist"); + expect(tabsList.getAttribute("class")).toContain("dark:bg-zinc-900/45"); + expect(tabsList.querySelector("[aria-hidden]")?.getAttribute("class")).toContain("dark:bg-zinc-800/80"); + + const emailInput = screen.getByLabelText("Email"); + fireEvent.change(emailInput, { target: { value: "hello@example.com" } }); + expect(emailInput).toHaveProperty("value", "hello@example.com"); + expect(emailInput.getAttribute("class")).toContain("bg-white/45"); + expect(emailInput.getAttribute("class")).toContain("dark:bg-zinc-900/50"); + expect(emailInput.getAttribute("class")).not.toContain("bg-background"); + + fireEvent.click(screen.getByRole("tab", { name: "Email & Password" })); + + screen.getByLabelText("Password"); + expect(screen.getByRole("tab", { name: "Email & Password" }).getAttribute("data-state")).toBe("active"); + }); +}); diff --git a/apps/dashboard/src/components/hosted-auth-preview.tsx b/apps/dashboard/src/components/hosted-auth-preview.tsx index 8931c2e3b..c561bba41 100644 --- a/apps/dashboard/src/components/hosted-auth-preview.tsx +++ b/apps/dashboard/src/components/hosted-auth-preview.tsx @@ -1,8 +1,8 @@ "use client"; -import { BrandIcons, Button, Input, Label, Separator, Tabs, TabsContent, TabsList, TabsTrigger, Typography, cn } from "@/components/ui"; +import { BrandIcons, Button, Input, Label, Separator, Typography, cn } from "@/components/ui"; import { KeyIcon } from "@phosphor-icons/react"; -import type { ReactElement, ReactNode } from "react"; +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState, type HTMLAttributes, type ReactElement, type ReactNode } from "react"; type HostedPreviewOAuthProvider = { id: string, @@ -25,6 +25,175 @@ const authTabsListClassName = "mb-4 h-10 w-full rounded-lg border border-black/[ const authTabsTriggerClassName = "h-8 flex-1 rounded-md py-0 text-sm font-medium text-muted-foreground transition-colors duration-300 hover:text-foreground/90 data-[state=active]:font-semibold data-[state=active]:text-foreground"; const authFooterClassName = "mt-6 border-t border-black/[0.06] pt-5 text-center text-sm dark:border-white/[0.10]"; const authFooterLinkClassName = "font-medium text-foreground/90 underline-offset-4 transition-colors hover:text-foreground hover:underline"; +const authInputClassName = "h-9 rounded-lg border-black/[0.08] bg-white/45 px-3 py-1 text-sm shadow-none ring-0 placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring dark:border-white/[0.15] dark:bg-zinc-900/50"; + +type HostedPreviewTabsContextValue = { + value: string, + setValue: (value: string) => void, +}; + +const HostedPreviewTabsContext = React.createContext(null); + +function useHostedPreviewTabsContext() { + const context = React.useContext(HostedPreviewTabsContext); + if (context == null) { + throw new Error("Hosted preview tabs components must be rendered inside HostedPreviewTabs"); + } + return context; +} + +function HostedPreviewTabs({ defaultValue, ...rest }: HTMLAttributes & { + defaultValue: string, +}) { + const [value, setValue] = useState(defaultValue); + const contextValue = useMemo(() => ({ + value, + setValue, + }), [value]); + + return ( + +
+ + ); +} + +function HostedPreviewTabsList({ className, children, ...props }: HTMLAttributes) { + const tabs = useHostedPreviewTabsContext(); + const containerRef = useRef(null); + const [indicatorStyle, setIndicatorStyle] = useState<{ left: number, top: number, width: number, height: number } | null>(null); + + const measure = useCallback(() => { + const container = containerRef.current; + if (container == null) { + return; + } + const activeTab = container.querySelector('[role="tab"][data-state="active"]'); + if (activeTab == null) { + setIndicatorStyle(null); + return; + } + setIndicatorStyle({ + left: activeTab.offsetLeft, + top: activeTab.offsetTop, + width: activeTab.offsetWidth, + height: activeTab.offsetHeight, + }); + }, []); + + useLayoutEffect(() => { + measure(); + }, [measure, tabs.value]); + + useLayoutEffect(() => { + const container = containerRef.current; + if (container == null || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => measure()); + observer.observe(container); + return () => observer.disconnect(); + }, [measure]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + const container = containerRef.current; + if (container == null) { + return; + } + const tabs = Array.from(container.querySelectorAll('[role="tab"]')); + const currentIndex = tabs.findIndex((tab) => tab === document.activeElement); + if (currentIndex === -1) { + return; + } + let nextIndex: number | null = null; + if (event.key === "ArrowRight") { + nextIndex = (currentIndex + 1) % tabs.length; + } else if (event.key === "ArrowLeft") { + nextIndex = (currentIndex - 1 + tabs.length) % tabs.length; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = tabs.length - 1; + } + if (nextIndex != null) { + event.preventDefault(); + tabs[nextIndex].focus(); + tabs[nextIndex].click(); + } + }, []); + + return ( +
+ {indicatorStyle != null && ( +
+ )} + {children} +
+ ); +} + +function HostedPreviewTabsTrigger({ className, value, onClick, ...props }: React.ButtonHTMLAttributes & { + value: string, +}) { + const tabs = useHostedPreviewTabsContext(); + const active = tabs.value === value; + const tabId = `hosted-preview-tab-${value}`; + const panelId = `hosted-preview-panel-${value}`; + + return ( + @@ -213,20 +381,19 @@ function HostedCredentialPreviewForm(props: { type: HostedAuthType, }) { return ( -
+ event.preventDefault()}>
{props.type === "sign-in" && ( - + event.preventDefault()}> Forgot password? )} @@ -235,11 +402,10 @@ function HostedCredentialPreviewForm(props: { id="hosted-preview-email-password-password" type="password" autoComplete={props.type === "sign-in" ? "current-password" : "new-password"} - className="h-10 rounded-xl border-border bg-background" - tabIndex={-1} + className={authInputClassName} /> - @@ -291,20 +457,20 @@ export function HostedAuthMethodPreview(props: { {enableSeparator && } {props.project.config.credentialEnabled && props.project.config.magicLinkEnabled ? ( - - + - Email - Email & Password - - + Email + Email & Password + + - - + + - - + + ) : props.project.config.credentialEnabled ? ( ) : props.project.config.magicLinkEnabled ? ( @@ -318,14 +484,14 @@ export function HostedAuthMethodPreview(props: { {type === "sign-in" ? (

Don't have an account?{" "} - + event.preventDefault()}> Sign up

) : (

Already have an account?{" "} - + event.preventDefault()}> Sign in