Onboarding auth page preview interactivity fix (#1721)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/hexclave/hexclave/blob/dev/CONTRIBUTING.md

-->

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Restores full interactivity in the hosted auth preview across onboarding
and project settings, replaces Radix tabs with lightweight
`HostedPreviewTabs` with ARIA and keyboard support, and enables
Enter‑to‑submit in the create‑project dialog. Also keeps onboarding
progress dots centered on a fixed rail.

- **New Features**
- Replaced Radix tabs with custom `HostedPreviewTabs` (animated
indicator, ARIA roles/ids for tabs and panels, Arrow keys/Home/End
navigation).
- Hosted-style inputs and links are now interactive; typing and tab
switching work.
- Wrapped the create-project dialog in a form with a shared submit
handler; Enter in the name field submits, the primary button is
`type="submit"`, and IME composition is guarded.

- **Bug Fixes**
- Removed `pointer-events-none`, `inert`, and overlay divs that blocked
the auth preview in onboarding and the project auth-methods page.
- Centered onboarding progress dots with a fixed-width rail
(`w-[150px]`) and a left-offset back arrow; dots keep accessible labels
and `aria-current="step"`.

<sup>Written for commit 7a7f1d27e0.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1721?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Enhanced the “Create Project” dialog with native form submit behavior
and Enter-key handling, including safer concurrent creation and clear
post-create navigation.
* Refreshed hosted authentication previews to support fully interactive
tab switching.
* **Bug Fixes**
* Improved onboarding bottom step navigation layout for more consistent
back-button and progress positioning.
* Removed interaction-blocking styling/attributes in hosted preview
screens so users can click and type normally.
* **Tests**
* Added UI-focused assertions to verify interactive preview behavior and
form submission semantics.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: armaan <armaan@stack-auth.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Armaan Jain 2026-07-05 21:17:51 -07:00 committed by GitHub
parent d78405d7e7
commit f2276ba39b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 471 additions and 156 deletions

View File

@ -93,43 +93,45 @@ export function OnboardingPage(props: OnboardingPageProps) {
</div>
<div className="onboarding-cascade fixed bottom-6 left-0 right-0 z-50 flex justify-center" style={{ "--cascade-i": 3 } as CSSProperties}>
<div className="relative flex items-center gap-[5px]">
<div className="relative flex h-5 w-[150px] items-center justify-center">
{props.onBack != null && (
<button
type="button"
onClick={props.onBack}
disabled={props.disabled}
aria-label="Go back to previous step"
className="absolute right-full mr-3 inline-flex h-5 w-5 items-center justify-center rounded-full text-foreground/40 transition-colors hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-40"
className="absolute left-0 inline-flex h-5 w-5 items-center justify-center rounded-full text-foreground/40 transition-colors hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-40"
>
<ArrowLeftIcon className="h-3.5 w-3.5" weight="bold" />
</button>
)}
{props.steps.map((step, index) => {
const isComplete = index < currentIndex;
const isCurrent = index === currentIndex;
const isClickable = isComplete && !props.disabled && props.onStepClick != null;
<span className="flex items-center gap-[5px]">
{props.steps.map((step, index) => {
const isComplete = index < currentIndex;
const isCurrent = index === currentIndex;
const isClickable = isComplete && !props.disabled && props.onStepClick != null;
return (
<button
key={step.id}
type="button"
disabled={!isClickable}
onClick={() => { if (isClickable) props.onStepClick?.(step.id); }}
aria-label={isClickable ? `Go to step: ${step.label}` : step.label}
aria-current={isCurrent ? "step" : undefined}
className={cn(
"rounded-full transition-colors duration-300 hover:transition-none",
isCurrent
? "h-[6px] w-5 bg-foreground"
: isComplete
? "h-[6px] w-[6px] cursor-pointer bg-foreground/40 hover:bg-foreground/60"
: "h-[6px] w-[6px] cursor-default bg-foreground/20",
)}
title={step.label}
/>
);
})}
return (
<button
key={step.id}
type="button"
disabled={!isClickable}
onClick={() => { if (isClickable) props.onStepClick?.(step.id); }}
aria-label={isClickable ? `Go to step: ${step.label}` : step.label}
aria-current={isCurrent ? "step" : undefined}
className={cn(
"rounded-full transition-colors duration-300 hover:transition-none",
isCurrent
? "h-[6px] w-5 bg-foreground"
: isComplete
? "h-[6px] w-[6px] cursor-pointer bg-foreground/40 hover:bg-foreground/60"
: "h-[6px] w-[6px] cursor-default bg-foreground/20",
)}
title={step.label}
/>
);
})}
</span>
</div>
</div>
</div>

View File

@ -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<HTMLFormElement>) => {
event.preventDefault();
if (!hasProjectName || creatingProject) {
return;
}
runAsynchronouslyWithAlert(createProject());
}, [createProject, creatingProject, hasProjectName]);
const handleProjectNameKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
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() {
</DialogDescription>
</DialogHeader>
<div className="space-y-5 px-6 py-6">
<div className="space-y-2">
<Label htmlFor="project-name">Project name</Label>
<DesignInput
id="project-name"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
placeholder="My Project"
/>
</div>
<div className="space-y-2">
<Label htmlFor="team-id">Team</Label>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<DesignSelectorDropdown
value={selectedTeamId ?? ""}
onValueChange={setSelectedTeamId}
placeholder="Select a team"
size="md"
className="w-full"
options={teams.map((team) => ({ value: team.id, label: team.displayName }))}
<form onSubmit={handleCreateProjectSubmit}>
<div className="space-y-5 px-6 py-6">
<div className="space-y-2">
<Label htmlFor="project-name">Project name</Label>
<DesignInput
id="project-name"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
onKeyDown={handleProjectNameKeyDown}
placeholder="My Project"
/>
<DesignButton variant="outline" onClick={() => setIsCreateTeamOpen(true)} className="rounded-xl sm:min-w-[152px]">
<PlusCircleIcon className="mr-2 h-4 w-4" />
Create Team
</DesignButton>
</div>
<div className="space-y-2">
<Label htmlFor="team-id">Team</Label>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<DesignSelectorDropdown
value={selectedTeamId ?? ""}
onValueChange={setSelectedTeamId}
placeholder="Select a team"
size="md"
className="w-full"
options={teams.map((team) => ({ value: team.id, label: team.displayName }))}
/>
<DesignButton type="button" variant="outline" onClick={() => setIsCreateTeamOpen(true)} className="rounded-xl sm:min-w-[152px]">
<PlusCircleIcon className="mr-2 h-4 w-4" />
Create Team
</DesignButton>
</div>
</div>
</div>
</div>
<DialogFooter className="border-t border-black/[0.08] px-6 py-4 dark:border-white/[0.08] sm:justify-end sm:space-x-2">
<DesignButton variant="outline" className="rounded-xl" onClick={() => router.push("/projects")} disabled={creatingProject}>
Cancel
</DesignButton>
<DesignButton
className="rounded-xl"
disabled={!hasProjectName || creatingProject}
loading={creatingProject}
onClick={() => {
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
</DesignButton>
</DialogFooter>
<DialogFooter className="border-t border-black/[0.08] px-6 py-4 dark:border-white/[0.08] sm:justify-end sm:space-x-2">
<DesignButton type="button" variant="outline" className="rounded-xl" onClick={() => router.push("/projects")} disabled={creatingProject}>
Cancel
</DesignButton>
<DesignButton
type="submit"
className="rounded-xl"
disabled={!hasProjectName || creatingProject}
loading={creatingProject}
>
Create Project
</DesignButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>

View File

@ -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<T>() {
}
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",

View File

@ -823,8 +823,7 @@ export function ProjectOnboardingWizard(props: {
>
<BrowserFrame url="your-website.com/signin" className="w-full">
<div className="flex min-h-[180px] items-center justify-center px-4 py-3 sm:min-h-[220px] md:min-h-[260px] md:px-5 md:py-4 lg:min-h-[300px]">
<div className="pointer-events-none relative flex w-full items-center justify-center" inert>
<div className="absolute inset-0 z-10 bg-transparent" />
<div className="relative flex w-full items-center justify-center">
<HostedAuthMethodPreview project={authPreviewProject} />
</div>
</div>

View File

@ -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("<form onSubmit={handleCreateProjectSubmit}>");
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(
<OnboardingPage
stepKey="apps-selection"
title="Select apps"
steps={[
{ id: "config_choice", label: "Config" },
{ id: "apps_selection", label: "Apps" },
]}
currentStep="apps_selection"
onBack={vi.fn()}
primaryAction={<button type="button">Continue</button>}
>
<div>Step body</div>
</OnboardingPage>,
);
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", () => {

View File

@ -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");
});
});

View File

@ -769,8 +769,7 @@ function LivePreviewBody({
<div className="self-stretch py-2 min-w-[400px] items-center">
<BrowserFrame url="your-website.com/signin">
<div className="flex flex-col items-center justify-center min-h-[400px]">
<div className='w-full sm:max-w-xs m-auto scale-90 pointer-events-none' inert>
<div className="absolute inset-0 bg-transparent z-10"></div>
<div className="w-full sm:max-w-xs m-auto scale-90">
<HostedAuthMethodPreview
project={{
displayName: projectDisplayName,

View File

@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { HostedAuthMethodPreview } from "./hosted-auth-preview";
const previewProject = {
displayName: "Preview App",
config: {
signUpEnabled: true,
credentialEnabled: true,
passkeyEnabled: false,
magicLinkEnabled: true,
oauthProviders: [
{ id: "google" },
{ id: "github" },
],
},
};
afterEach(() => {
cleanup();
});
describe("HostedAuthMethodPreview", () => {
it("uses interactive hosted-style tabs and input surfaces", () => {
render(<HostedAuthMethodPreview project={previewProject} />);
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");
});
});

View File

@ -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<HostedPreviewTabsContextValue | null>(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<HTMLDivElement> & {
defaultValue: string,
}) {
const [value, setValue] = useState(defaultValue);
const contextValue = useMemo<HostedPreviewTabsContextValue>(() => ({
value,
setValue,
}), [value]);
return (
<HostedPreviewTabsContext.Provider value={contextValue}>
<div {...rest} />
</HostedPreviewTabsContext.Provider>
);
}
function HostedPreviewTabsList({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
const tabs = useHostedPreviewTabsContext();
const containerRef = useRef<HTMLDivElement | null>(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<HTMLElement>('[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<HTMLDivElement>) => {
const container = containerRef.current;
if (container == null) {
return;
}
const tabs = Array.from(container.querySelectorAll<HTMLElement>('[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 (
<div
ref={containerRef}
role="tablist"
className={cn("stack-scope relative inline-flex h-9 items-center justify-center rounded-lg border border-black/[0.08] bg-zinc-100/70 p-1 text-muted-foreground dark:border-white/[0.10] dark:bg-zinc-900/45", className)}
onKeyDown={handleKeyDown}
{...props}
>
{indicatorStyle != null && (
<div
aria-hidden
className="pointer-events-none absolute rounded-md border border-black/[0.08] bg-white/80 shadow-sm transition-[left,top,width,height] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] dark:border-white/[0.10] dark:bg-zinc-800/80 dark:ring-1 dark:ring-white/[0.06]"
style={indicatorStyle}
/>
)}
{children}
</div>
);
}
function HostedPreviewTabsTrigger({ className, value, onClick, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & {
value: string,
}) {
const tabs = useHostedPreviewTabsContext();
const active = tabs.value === value;
const tabId = `hosted-preview-tab-${value}`;
const panelId = `hosted-preview-panel-${value}`;
return (
<button
type="button"
role="tab"
id={tabId}
aria-selected={active}
aria-controls={panelId}
tabIndex={active ? 0 : -1}
data-state={active ? "active" : "inactive"}
className={cn(
"relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium text-muted-foreground ring-offset-background transition-colors duration-300 hover:text-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:font-semibold data-[state=active]:text-foreground",
className,
)}
onClick={(event) => {
tabs.setValue(value);
onClick?.(event);
}}
{...props}
/>
);
}
function HostedPreviewTabsContent({ className, value, ...props }: HTMLAttributes<HTMLDivElement> & {
value: string,
}) {
const tabs = useHostedPreviewTabsContext();
if (tabs.value !== value) {
return null;
}
const panelId = `hosted-preview-panel-${value}`;
const tabId = `hosted-preview-tab-${value}`;
return (
<div
role="tabpanel"
id={panelId}
aria-labelledby={tabId}
data-state="active"
className={cn("mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className)}
{...props}
/>
);
}
const providerButtonClassNames = new Map<string, string>([
["google", "bg-white text-black hover:bg-zinc-50 border border-border shadow-sm"],
@ -145,8 +314,8 @@ function OAuthPreviewButton(props: {
const style = getProviderStyle(props.provider);
return (
<Button
type="button"
variant="plain"
tabIndex={-1}
className={cn("stack-scope relative h-10 w-full overflow-visible rounded-xl font-medium transition-all duration-150", getProviderButtonClassName(props.provider))}
>
<div className="flex w-full items-center gap-3">
@ -177,8 +346,8 @@ function PasskeyPreviewButton(props: {
}) {
return (
<Button
type="button"
variant="plain"
tabIndex={-1}
className="stack-scope h-10 rounded-xl border border-transparent bg-primary font-medium text-primary-foreground shadow-sm transition-all duration-150 hover:bg-primary/90"
>
<div className="flex w-full items-center gap-4">
@ -193,16 +362,15 @@ function PasskeyPreviewButton(props: {
function HostedMagicLinkPreviewForm() {
return (
<form className="stack-scope flex flex-col items-stretch" noValidate>
<form className="stack-scope flex flex-col items-stretch" noValidate onSubmit={(event) => event.preventDefault()}>
<Label htmlFor="hosted-preview-email" className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Email</Label>
<Input
id="hosted-preview-email"
type="email"
autoComplete="email"
className="h-10 rounded-xl border-border bg-background"
tabIndex={-1}
className={authInputClassName}
/>
<Button type="button" className="mt-6 h-10 rounded-xl font-semibold shadow-sm hover:shadow" tabIndex={-1}>
<Button type="button" className="mt-6 h-10 rounded-xl font-semibold shadow-sm hover:shadow">
Send email
</Button>
</form>
@ -213,20 +381,19 @@ function HostedCredentialPreviewForm(props: {
type: HostedAuthType,
}) {
return (
<form className="stack-scope flex flex-col items-stretch" noValidate>
<form className="stack-scope flex flex-col items-stretch" noValidate onSubmit={(event) => event.preventDefault()}>
<Label htmlFor="hosted-preview-email-password-email" className="mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Email</Label>
<Input
id="hosted-preview-email-password-email"
type="email"
autoComplete="email"
className="h-10 rounded-xl border-border bg-background"
tabIndex={-1}
className={authInputClassName}
/>
<div className="mb-1.5 mt-4 flex items-center justify-between">
<Label htmlFor="hosted-preview-email-password-password" className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Password</Label>
{props.type === "sign-in" && (
<a href="#" className="text-xs text-muted-foreground hover:text-foreground" tabIndex={-1}>
<a href="#" className="text-xs text-muted-foreground hover:text-foreground" onClick={(event) => event.preventDefault()}>
Forgot password?
</a>
)}
@ -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}
/>
<Button type="button" className="mt-6 h-10 rounded-xl font-semibold shadow-sm hover:shadow" tabIndex={-1}>
<Button type="button" className="mt-6 h-10 rounded-xl font-semibold shadow-sm hover:shadow">
{props.type === "sign-in" ? "Sign In" : "Sign Up"}
</Button>
</form>
@ -291,20 +457,20 @@ export function HostedAuthMethodPreview(props: {
{enableSeparator && <SeparatorWithText text="Or continue with" />}
{props.project.config.credentialEnabled && props.project.config.magicLinkEnabled ? (
<Tabs defaultValue={props.firstTab || "magic-link"} className="w-full">
<TabsList className={cn(authTabsListClassName, {
<HostedPreviewTabs defaultValue={props.firstTab || "magic-link"} className="w-full">
<HostedPreviewTabsList className={cn(authTabsListClassName, {
"flex-row-reverse": props.firstTab === "password",
})}>
<TabsTrigger value="magic-link" className={authTabsTriggerClassName} tabIndex={-1}>Email</TabsTrigger>
<TabsTrigger value="password" className={authTabsTriggerClassName} tabIndex={-1}>Email & Password</TabsTrigger>
</TabsList>
<TabsContent value="magic-link" className="focus-visible:outline-none focus-visible:ring-0">
<HostedPreviewTabsTrigger value="magic-link" className={authTabsTriggerClassName}>Email</HostedPreviewTabsTrigger>
<HostedPreviewTabsTrigger value="password" className={authTabsTriggerClassName}>Email & Password</HostedPreviewTabsTrigger>
</HostedPreviewTabsList>
<HostedPreviewTabsContent value="magic-link" className="focus-visible:outline-none focus-visible:ring-0">
<HostedMagicLinkPreviewForm />
</TabsContent>
<TabsContent value="password" className="focus-visible:outline-none focus-visible:ring-0">
</HostedPreviewTabsContent>
<HostedPreviewTabsContent value="password" className="focus-visible:outline-none focus-visible:ring-0">
<HostedCredentialPreviewForm type={type} />
</TabsContent>
</Tabs>
</HostedPreviewTabsContent>
</HostedPreviewTabs>
) : props.project.config.credentialEnabled ? (
<HostedCredentialPreviewForm type={type} />
) : props.project.config.magicLinkEnabled ? (
@ -318,14 +484,14 @@ export function HostedAuthMethodPreview(props: {
{type === "sign-in" ? (
<p className="text-muted-foreground">
Don&apos;t have an account?{" "}
<a href="#" className={authFooterLinkClassName} tabIndex={-1}>
<a href="#" className={authFooterLinkClassName} onClick={(event) => event.preventDefault()}>
Sign up
</a>
</p>
) : (
<p className="text-muted-foreground">
Already have an account?{" "}
<a href="#" className={authFooterLinkClassName} tabIndex={-1}>
<a href="#" className={authFooterLinkClassName} onClick={(event) => event.preventDefault()}>
Sign in
</a>
</p>