Skip to content

Commit

Permalink
feat(elements): Introduce router, scaffold sign in components (#2426)
Browse files Browse the repository at this point in the history
* feat(elements): Spike out routing

* feat(elements): Move some files around, create initial package structure

* chore(elements): Adjust example usage of the package

* chore(elements): Add comments

* chore(elements): Tweak error message to make it more accurate

* chore(repo): Add changeset
  • Loading branch information
BRKalow authored Dec 20, 2023
1 parent e0bddab commit 2504eb3
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 6 deletions.
2 changes: 2 additions & 0 deletions .changeset/short-cheetahs-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 0 additions & 3 deletions packages/elements/examples/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { HelloWorld } from '@clerk/elements';
import Image from 'next/image';

export default function Home() {
Expand Down Expand Up @@ -40,8 +39,6 @@ export default function Home() {
/>
</div>

<HelloWorld />

<div className='mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left'>
<a
href='https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { SignIn, SignInFactorOne, SignInFactorTwo, SignInSSOCallback, SignInStart } from '@clerk/elements'

export default function SignInPage() {
return (
<SignIn>
<SignInStart>
Start child
</SignInStart>
<SignInFactorOne>
Factor one child
</SignInFactorOne>
<SignInFactorTwo>
Factor two child
</SignInFactorTwo>
<SignInSSOCallback />
</SignIn>
)
}
9 changes: 6 additions & 3 deletions packages/elements/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export function HelloWorld(): JSX.Element {
return <p>Hello World!</p>;
}
'use client';
import { useNextRouter } from './internals/router';

export * from './sign-in';

export { useNextRouter };
42 changes: 42 additions & 0 deletions packages/elements/src/internals/router-react.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';
import { createContext, useContext } from 'react';

import type { ClerkHostRouter, ClerkRouter } from './router';
import { createClerkRouter } from './router';

export const ClerkRouterContext = createContext<ClerkRouter | null>(null);

function useClerkRouter() {
return useContext(ClerkRouterContext);
}

export function Router({
children,
router,
basePath,
}: {
router: ClerkHostRouter;
children: React.ReactNode;
basePath?: string;
}) {
const clerkRouter = createClerkRouter(router, basePath);

return <ClerkRouterContext.Provider value={clerkRouter}>{children}</ClerkRouterContext.Provider>;
}

type RouteProps = { path?: string; index?: boolean };

export function Route({ path, children, index }: RouteProps & { children: React.ReactNode }) {
// check for parent router, if exists, create child router, otherwise create one
const parentRouter = useClerkRouter();

if (!path && !index) {
return children;
}

if (!parentRouter?.match(path, index)) {
return null;
}

return children;
}
102 changes: 102 additions & 0 deletions packages/elements/src/internals/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { usePathname, useRouter, useSearchParams } from 'next/navigation';

/**
* This type represents a generic router interface that Clerk relies on to interact with the host router.
*/
export type ClerkHostRouter = {
push: (path: string) => void;
replace: (path: string) => void;
pathname: () => string;
searchParams: () => URLSearchParams;
};

/**
* Internal Clerk router, used by Clerk components to interact with the host's router.
*/
export type ClerkRouter = {
/**
* Creates a child router instance scoped to the provided base path.
*/
child: (childBasePath: string) => ClerkRouter;
/**
* Matches the provided path against the router's current path. If index is provided, matches against the root route of the router.
*/
match: (path?: string, index?: boolean) => boolean;
/**
* Navigates to the provided path via a history push
*/
push: ClerkHostRouter['push'];
/**
* Navigates to the provided path via a history replace
*/
replace: ClerkHostRouter['replace'];
/**
* Returns the current pathname (including the base path)
*/
pathname: ClerkHostRouter['pathname'];
/**
* Returns the current search params
*/
searchParams: ClerkHostRouter['searchParams'];
};

/**
* Ensures the provided path has a leading slash and no trailing slash
*/
function normalizePath(path: string) {
const pathNoTrailingSlash = path.replace(/\/$/, '');
return pathNoTrailingSlash.startsWith('/') ? pathNoTrailingSlash : `/${pathNoTrailingSlash}`;
}

/**
* Factory function to create an instance of ClerkRouter with the provided host router.
*
* @param router host router instance to be used by the router
* @param basePath base path of the router, navigation and matching will be scoped to this path
* @returns A ClerkRouter instance
*/
export function createClerkRouter(router: ClerkHostRouter, basePath: string = '/'): ClerkRouter {
const normalizedBasePath = normalizePath(basePath);

function match(path?: string, index?: boolean) {
const pathToMatch = path ?? (index && '/');

if (!pathToMatch) {
throw new Error('[clerk] router.match() requires either a path to match, or the index flag must be set to true.');
}

const normalizedPath = normalizePath(pathToMatch);

return normalizePath(`${normalizedBasePath}${normalizedPath}`) === normalizePath(router.pathname());
}

function child(childBasePath: string) {
return createClerkRouter(router, `${normalizedBasePath}/${normalizePath(childBasePath)}`);
}

return {
child,
match,
push: router.push,
replace: router.replace,
pathname: router.pathname,
searchParams: router.searchParams,
};
}

/**
* Framework specific router integrations
*/

export const useNextRouter = (): ClerkHostRouter => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

return {
push: (path: string) => router.push(path),
replace: (path: string) => router.replace(path),
pathname: () => pathname,
searchParams: () => searchParams,
};
};
48 changes: 48 additions & 0 deletions packages/elements/src/sign-in/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';
import { type ClerkHostRouter, useNextRouter } from '../internals/router';
import { Route, Router } from '../internals/router-react';

export function SignIn({ children }: { router?: ClerkHostRouter; children: React.ReactNode }): JSX.Element {
// TODO: eventually we'll rely on the framework SDK to specify its host router, but for now we'll default to Next.js
const router = useNextRouter();

return (
<Router
router={router}
basePath='/sign-in'
>
{children}
</Router>
);
}

export function SignInStart({ children }: { children: React.ReactNode }) {
return (
<Route index>
Start
{children}
</Route>
);
}

export function SignInFactorOne({ children }: { children: React.ReactNode }) {
return (
<Route path='factor-one'>
Factor One
{children}
</Route>
);
}

export function SignInFactorTwo({ children }: { children: React.ReactNode }) {
return (
<Route path='factor-two'>
Factor Two
{children}
</Route>
);
}

export function SignInSSOCallback() {
return <Route path='sso-callback'>SSOCallback</Route>;
}

0 comments on commit 2504eb3

Please sign in to comment.