mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
parent
d78405d7e7
commit
f2276ba39b
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
46
apps/dashboard/src/components/hosted-auth-preview.test.tsx
Normal file
46
apps/dashboard/src/components/hosted-auth-preview.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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'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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user