diff --git a/.changeset/young-frogs-enjoy.md b/.changeset/young-frogs-enjoy.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/young-frogs-enjoy.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/jest.config.js b/packages/clerk-js/jest.config.js index 0cb9f26635..63a15d2da3 100644 --- a/packages/clerk-js/jest.config.js +++ b/packages/clerk-js/jest.config.js @@ -1,5 +1,7 @@ const { name } = require('./package.json'); +const uiRetheme = process.env.CLERK_UI_RETHEME === '1' || process.env.CLERK_UI_RETHEME === 'true'; + /** @type {import('ts-jest').JestConfigWithTsJest} */ const config = { displayName: name.replace('@clerk', ''), @@ -14,8 +16,7 @@ const config = { '/ui/.*/__tests__/.*.test.[jt]sx?$', '/(core|utils)/.*.test.[jt]sx?$', ], - testPathIgnorePatterns: ['/node_modules/'], - + testPathIgnorePatterns: ['/node_modules/', uiRetheme ? '/src/ui/' : '/src/ui-retheme/'], collectCoverage: false, coverageProvider: 'v8', coverageDirectory: 'coverage', diff --git a/packages/clerk-js/src/ui-retheme/Components.tsx b/packages/clerk-js/src/ui-retheme/Components.tsx new file mode 100644 index 0000000000..ad2585cc7d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/Components.tsx @@ -0,0 +1,343 @@ +import { createDeferredPromise } from '@clerk/shared'; +import { useSafeLayoutEffect } from '@clerk/shared/react'; +import type { + Appearance, + Clerk, + ClerkOptions, + CreateOrganizationProps, + EnvironmentResource, + OrganizationProfileProps, + SignInProps, + SignUpProps, + UserProfileProps, +} from '@clerk/types'; +import React, { Suspense } from 'react'; + +import { clerkUIErrorDOMElementNotFound } from '../core/errors'; +import { buildVirtualRouterUrl } from '../utils'; +import type { AppearanceCascade } from './customizables/parseAppearance'; +// NOTE: Using `./hooks` instead of `./hooks/useClerkModalStateParams` will increase the bundle size +import { useClerkModalStateParams } from './hooks/useClerkModalStateParams'; +import type { ClerkComponentName } from './lazyModules/components'; +import { + CreateOrganizationModal, + ImpersonationFab, + OrganizationProfileModal, + preloadComponent, + SignInModal, + SignUpModal, + UserProfileModal, +} from './lazyModules/components'; +import { + LazyComponentRenderer, + LazyImpersonationFabProvider, + LazyModalRenderer, + LazyProviders, +} from './lazyModules/providers'; +import type { AvailableComponentProps } from './types'; + +const ROOT_ELEMENT_ID = 'clerk-components'; + +export type ComponentControls = { + mountComponent: (params: { + appearanceKey: Uncapitalize; + name: ClerkComponentName; + node: HTMLDivElement; + props?: AvailableComponentProps; + }) => void; + unmountComponent: (params: { node: HTMLDivElement }) => void; + updateProps: (params: { + appearance?: Appearance | undefined; + options?: ClerkOptions | undefined; + node?: HTMLDivElement; + props?: unknown; + }) => void; + openModal: ( + modal: T, + props: T extends 'signIn' ? SignInProps : T extends 'signUp' ? SignUpProps : UserProfileProps, + ) => void; + closeModal: (modal: 'signIn' | 'signUp' | 'userProfile' | 'organizationProfile' | 'createOrganization') => void; + // Special case, as the impersonation fab mounts automatically + mountImpersonationFab: () => void; +}; + +interface HtmlNodeOptions { + key: string; + name: ClerkComponentName; + appearanceKey: Uncapitalize; + props?: AvailableComponentProps; +} + +interface ComponentsProps { + clerk: Clerk; + environment: EnvironmentResource; + options: ClerkOptions; + onComponentsMounted: () => void; +} + +interface ComponentsState { + appearance: Appearance | undefined; + options: ClerkOptions | undefined; + signInModal: null | SignInProps; + signUpModal: null | SignUpProps; + userProfileModal: null | UserProfileProps; + organizationProfileModal: null | OrganizationProfileProps; + createOrganizationModal: null | CreateOrganizationProps; + nodes: Map; + impersonationFab: boolean; +} + +let portalCt = 0; + +function assertDOMElement(element: HTMLElement): asserts element { + if (!element) { + clerkUIErrorDOMElementNotFound(); + } +} + +export const mountComponentRenderer = (clerk: Clerk, environment: EnvironmentResource, options: ClerkOptions) => { + // TODO @ui-retheme: remove + console.log('%c You are using the ui-retheme components ', 'background: blue; color: white;font-size:2rem;'); + + // TODO: Init of components should start + // before /env and /client requests + let clerkRoot = document.getElementById(ROOT_ELEMENT_ID); + + if (!clerkRoot) { + clerkRoot = document.createElement('div'); + clerkRoot.setAttribute('id', 'clerk-components'); + document.body.appendChild(clerkRoot); + } + + let componentsControlsResolver: Promise | undefined; + + return { + ensureMounted: async (opts?: { preloadHint: ClerkComponentName }) => { + const { preloadHint } = opts || {}; + // This mechanism ensures that mountComponentControls will only be called once + // and any calls to .mount before mountComponentControls resolves will fire in order. + // Otherwise, we risk having components rendered multiple times, or having + // .unmountComponent incorrectly called before the component is rendered + if (!componentsControlsResolver) { + const deferredPromise = createDeferredPromise(); + if (preloadHint) { + void preloadComponent(preloadHint); + } + componentsControlsResolver = import('./lazyModules/common').then(({ createRoot }) => { + createRoot(clerkRoot!).render( + , + ); + return deferredPromise.promise.then(() => componentsControls); + }); + } + return componentsControlsResolver.then(controls => controls); + }, + }; +}; + +export type MountComponentRenderer = typeof mountComponentRenderer; + +const componentsControls = {} as ComponentControls; + +const componentNodes = Object.freeze({ + SignUp: 'signUpModal', + SignIn: 'signInModal', + UserProfile: 'userProfileModal', + OrganizationProfile: 'organizationProfileModal', + CreateOrganization: 'createOrganizationModal', +}) as any; + +const Components = (props: ComponentsProps) => { + const [state, setState] = React.useState({ + appearance: props.options.appearance, + options: props.options, + signInModal: null, + signUpModal: null, + userProfileModal: null, + organizationProfileModal: null, + createOrganizationModal: null, + nodes: new Map(), + impersonationFab: false, + }); + const { signInModal, signUpModal, userProfileModal, organizationProfileModal, createOrganizationModal, nodes } = + state; + + const { urlStateParam, clearUrlStateParam, decodedRedirectParams } = useClerkModalStateParams(); + + useSafeLayoutEffect(() => { + if (decodedRedirectParams) { + setState(s => ({ + ...s, + [componentNodes[decodedRedirectParams.componentName]]: true, + })); + } + + componentsControls.mountComponent = params => { + const { node, name, props, appearanceKey } = params; + + assertDOMElement(node); + setState(s => { + s.nodes.set(node, { key: `p${++portalCt}`, name, props, appearanceKey }); + return { ...s, nodes }; + }); + }; + + componentsControls.unmountComponent = params => { + const { node } = params; + setState(s => { + s.nodes.delete(node); + return { ...s, nodes }; + }); + }; + + componentsControls.updateProps = ({ node, props, ...restProps }) => { + if (node && props && typeof props === 'object') { + const nodeOptions = state.nodes.get(node); + if (nodeOptions) { + nodeOptions.props = { ...props }; + setState(s => ({ ...s })); + return; + } + } + setState(s => ({ ...s, ...restProps })); + }; + + componentsControls.closeModal = name => { + clearUrlStateParam(); + setState(s => ({ ...s, [name + 'Modal']: null })); + }; + + componentsControls.openModal = (name, props) => { + setState(s => ({ ...s, [name + 'Modal']: props })); + }; + + componentsControls.mountImpersonationFab = () => { + setState(s => ({ ...s, impersonationFab: true })); + }; + + props.onComponentsMounted(); + }, []); + + const mountedSignInModal = ( + componentsControls.closeModal('signIn')} + onExternalNavigate={() => componentsControls.closeModal('signIn')} + startPath={buildVirtualRouterUrl({ base: '/sign-in', path: urlStateParam?.path })} + componentName={'SignInModal'} + > + + + + ); + + const mountedSignUpModal = ( + componentsControls.closeModal('signUp')} + onExternalNavigate={() => componentsControls.closeModal('signUp')} + startPath={buildVirtualRouterUrl({ base: '/sign-up', path: urlStateParam?.path })} + componentName={'SignUpModal'} + > + + + + ); + + const mountedUserProfileModal = ( + componentsControls.closeModal('userProfile')} + onExternalNavigate={() => componentsControls.closeModal('userProfile')} + startPath={buildVirtualRouterUrl({ base: '/user', path: urlStateParam?.path })} + componentName={'SignUpModal'} + modalContainerSx={{ alignItems: 'center' }} + modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} + > + + + ); + + const mountedOrganizationProfileModal = ( + componentsControls.closeModal('organizationProfile')} + onExternalNavigate={() => componentsControls.closeModal('organizationProfile')} + startPath={buildVirtualRouterUrl({ base: '/organizationProfile', path: urlStateParam?.path })} + componentName={'OrganizationProfileModal'} + modalContainerSx={{ alignItems: 'center' }} + modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} + > + + + ); + + const mountedCreateOrganizationModal = ( + componentsControls.closeModal('createOrganization')} + onExternalNavigate={() => componentsControls.closeModal('createOrganization')} + startPath={buildVirtualRouterUrl({ base: '/createOrganization', path: urlStateParam?.path })} + componentName={'CreateOrganizationModal'} + modalContainerSx={{ alignItems: 'center' }} + modalContentSx={t => ({ height: `min(${t.sizes.$120}, calc(100% - ${t.sizes.$12}))`, margin: 0 })} + > + + + ); + + return ( + + + {[...nodes].map(([node, component]) => { + return ( + + ); + })} + + {signInModal && mountedSignInModal} + {signUpModal && mountedSignUpModal} + {userProfileModal && mountedUserProfileModal} + {organizationProfileModal && mountedOrganizationProfileModal} + {createOrganizationModal && mountedCreateOrganizationModal} + {state.impersonationFab && ( + + + + )} + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/BlockButtons.tsx b/packages/clerk-js/src/ui-retheme/common/BlockButtons.tsx new file mode 100644 index 0000000000..21edca4a60 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/BlockButtons.tsx @@ -0,0 +1,40 @@ +import { descriptors, Icon } from '../customizables'; +import { ArrowBlockButton } from '../elements'; +import { Plus } from '../icons'; +import type { PropsOfComponent } from '../styledSystem'; + +type BlockButtonProps = PropsOfComponent; + +export const BlockButton = (props: BlockButtonProps) => { + const { id, ...rest } = props; + return ( + + ); +}; + +export const AddBlockButton = (props: BlockButtonProps) => { + const { leftIcon, ...rest } = props; + return ( + ({ justifyContent: 'flex-start', gap: theme.space.$2 })} + leftIcon={ + ({ + width: theme.sizes.$2x5, + height: theme.sizes.$2x5, + })} + /> + } + > + {props.children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/CalloutWithAction.tsx b/packages/clerk-js/src/ui-retheme/common/CalloutWithAction.tsx new file mode 100644 index 0000000000..76077ed37d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/CalloutWithAction.tsx @@ -0,0 +1,63 @@ +import type { ComponentType, MouseEvent, PropsWithChildren } from 'react'; + +import { Col, Flex, Icon, Link, Text } from '../customizables'; +import type { LocalizationKey } from '../localization'; +import type { ThemableCssProp } from '../styledSystem'; + +type CalloutWithActionProps = { + text?: LocalizationKey | string; + textSx?: ThemableCssProp; + actionLabel?: LocalizationKey; + onClick?: (e: MouseEvent) => Promise; + icon: ComponentType; +}; +export const CalloutWithAction = (props: PropsWithChildren) => { + const { icon, text, textSx, actionLabel, onClick: onClickProp } = props; + + const onClick = (e: MouseEvent) => { + void onClickProp?.(e); + }; + + return ( + ({ + background: theme.colors.$blackAlpha50, + padding: `${theme.space.$2x5} ${theme.space.$4}`, + justifyContent: 'space-between', + alignItems: 'flex-start', + borderRadius: theme.radii.$md, + })} + > + + ({ marginTop: t.space.$1 })} + /> + + ({ + lineHeight: t.lineHeights.$base, + }), + textSx, + ]} + localizationKey={text} + > + {props.children} + + + {actionLabel && ( + + )} + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/CustomPageContentContainer.tsx b/packages/clerk-js/src/ui-retheme/common/CustomPageContentContainer.tsx new file mode 100644 index 0000000000..e134a08f89 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/CustomPageContentContainer.tsx @@ -0,0 +1,28 @@ +import { Col, descriptors } from '../customizables'; +import { CardAlert, NavbarMenuButtonRow, useCardState, withCardStateProvider } from '../elements'; +import type { CustomPageContent } from '../utils'; +import { ExternalElementMounter } from '../utils'; + +export const CustomPageContentContainer = withCardStateProvider( + ({ mount, unmount }: Omit) => { + const card = useCardState(); + return ( + + {card.error} + + + + + + ); + }, +); diff --git a/packages/clerk-js/src/ui-retheme/common/EmailLinkCompleteFlowCard.tsx b/packages/clerk-js/src/ui-retheme/common/EmailLinkCompleteFlowCard.tsx new file mode 100644 index 0000000000..af37d800d8 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/EmailLinkCompleteFlowCard.tsx @@ -0,0 +1,61 @@ +import { withCardStateProvider } from '../elements'; +import { localizationKeys } from '../localization'; +import type { EmailLinkVerifyProps } from './EmailLinkVerify'; +import { EmailLinkVerify } from './EmailLinkVerify'; + +const signInLocalizationKeys = { + verified: { + title: localizationKeys('signIn.emailLink.verified.title'), + subtitle: localizationKeys('signIn.emailLink.verified.subtitle'), + }, + verified_switch_tab: { + title: localizationKeys('signIn.emailLink.verified.title'), + subtitle: localizationKeys('signIn.emailLink.verifiedSwitchTab.subtitle'), + }, + loading: { + title: localizationKeys('signIn.emailLink.loading.title'), + subtitle: localizationKeys('signIn.emailLink.loading.subtitle'), + }, + failed: { + title: localizationKeys('signIn.emailLink.failed.title'), + subtitle: localizationKeys('signIn.emailLink.failed.subtitle'), + }, + expired: { + title: localizationKeys('signIn.emailLink.expired.title'), + subtitle: localizationKeys('signIn.emailLink.expired.subtitle'), + }, +}; + +const signUpLocalizationKeys = { + ...signInLocalizationKeys, + verified: { + ...signInLocalizationKeys.verified, + title: localizationKeys('signUp.emailLink.verified.title'), + }, + verified_switch_tab: { + ...signInLocalizationKeys.verified_switch_tab, + title: localizationKeys('signUp.emailLink.verified.title'), + }, + loading: { + ...signInLocalizationKeys.loading, + title: localizationKeys('signUp.emailLink.loading.title'), + }, +}; + +export const SignInEmailLinkFlowComplete = withCardStateProvider((props: Omit) => { + return ( + + ); +}); + +export const SignUpEmailLinkFlowComplete = withCardStateProvider((props: Omit) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/common/EmailLinkStatusCard.tsx b/packages/clerk-js/src/ui-retheme/common/EmailLinkStatusCard.tsx new file mode 100644 index 0000000000..953a707026 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/EmailLinkStatusCard.tsx @@ -0,0 +1,103 @@ +import React from 'react'; + +import type { VerificationStatus } from '../../utils/getClerkQueryParam'; +import type { LocalizationKey } from '../customizables'; +import { Col, descriptors, Flex, Flow, Icon, localizationKeys, Spinner, Text } from '../customizables'; +import { Card, CardAlert, Header } from '../elements'; +import { useCardState } from '../elements/contexts'; +import { ExclamationTriangle, SwitchArrows, TickShield } from '../icons'; +import type { InternalTheme } from '../styledSystem'; +import { animations } from '../styledSystem'; + +type EmailLinkStatusCardProps = React.PropsWithChildren<{ + title: LocalizationKey; + subtitle: LocalizationKey; + status: VerificationStatus; +}>; + +const StatusToIcon: Record, React.ComponentType> = { + verified: TickShield, + verified_switch_tab: SwitchArrows, + expired: ExclamationTriangle, + failed: ExclamationTriangle, +}; + +const statusToColor = (theme: InternalTheme, status: Exclude) => + ({ + verified: theme.colors.$success500, + verified_switch_tab: theme.colors.$primary500, + expired: theme.colors.$warning500, + failed: theme.colors.$danger500, + }[status]); + +export const EmailLinkStatusCard = (props: EmailLinkStatusCardProps) => { + const card = useCardState(); + return ( + + + {card.error} + + + + + + + + + + ); +}; + +const StatusRow = (props: { status: VerificationStatus }) => { + return ( + + {props.status === 'loading' ? ( + ({ margin: `${theme.space.$12} 0` })} + /> + ) : ( + <> + + + + )} + + ); +}; + +const StatusIcon = (props: { status: Exclude }) => { + const { status } = props; + + return ( + ({ + width: theme.sizes.$24, + height: theme.sizes.$24, + borderRadius: theme.radii.$circle, + backgroundColor: theme.colors.$blackAlpha100, + color: statusToColor(theme, status), + animation: `${animations.dropdownSlideInScaleAndFade} 500ms ease`, + })} + > + ({ height: theme.sizes.$6, width: theme.sizes.$5 })} + /> + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/EmailLinkVerify.tsx b/packages/clerk-js/src/ui-retheme/common/EmailLinkVerify.tsx new file mode 100644 index 0000000000..ecf1bb13dd --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/EmailLinkVerify.tsx @@ -0,0 +1,60 @@ +import { EmailLinkErrorCode, isEmailLinkError } from '@clerk/shared/error'; +import React from 'react'; + +import type { VerificationStatus } from '../../utils'; +import { completeSignUpFlow } from '../../utils'; +import { useCoreClerk, useCoreSignUp } from '../contexts'; +import type { LocalizationKey } from '../localization'; +import { useRouter } from '../router'; +import { sleep } from '../utils'; +import { EmailLinkStatusCard } from './EmailLinkStatusCard'; + +export type EmailLinkVerifyProps = { + redirectUrlComplete?: string; + redirectUrl?: string; + verifyEmailPath?: string; + verifyPhonePath?: string; + texts: Record; +}; + +export const EmailLinkVerify = (props: EmailLinkVerifyProps) => { + const { redirectUrl, redirectUrlComplete, verifyEmailPath, verifyPhonePath } = props; + const { handleEmailLinkVerification } = useCoreClerk(); + const { navigate } = useRouter(); + const signUp = useCoreSignUp(); + const [verificationStatus, setVerificationStatus] = React.useState('loading'); + + const startVerification = async () => { + try { + // Avoid loading flickering + await sleep(750); + await handleEmailLinkVerification({ redirectUrlComplete, redirectUrl }, navigate); + setVerificationStatus('verified_switch_tab'); + await sleep(750); + return completeSignUpFlow({ + signUp, + verifyEmailPath, + verifyPhonePath, + navigate, + }); + } catch (err) { + let status: VerificationStatus = 'failed'; + if (isEmailLinkError(err) && err.code === EmailLinkErrorCode.Expired) { + status = 'expired'; + } + setVerificationStatus(status); + } + }; + + React.useEffect(() => { + void startVerification(); + }, []); + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/Gate.tsx b/packages/clerk-js/src/ui-retheme/common/Gate.tsx new file mode 100644 index 0000000000..0c1a016742 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/Gate.tsx @@ -0,0 +1,63 @@ +import type { CheckAuthorization } from '@clerk/types'; +import type { ComponentType, PropsWithChildren, ReactNode } from 'react'; +import React, { useEffect } from 'react'; + +import { useCoreSession } from '../contexts'; +import { useRouter } from '../router'; + +type GateParams = Parameters[0]; +type GateProps = PropsWithChildren< + GateParams & { + fallback?: ReactNode; + redirectTo?: string; + } +>; + +export const useGate = (params: GateParams) => { + const { experimental__checkAuthorization } = useCoreSession(); + + return { + isAuthorizedUser: experimental__checkAuthorization(params), + }; +}; + +export const Gate = (gateProps: GateProps) => { + const { children, fallback, redirectTo, ...restAuthorizedParams } = gateProps; + + const { isAuthorizedUser } = useGate(restAuthorizedParams); + + const { navigate } = useRouter(); + + useEffect(() => { + // wait for promise to resolve + if (typeof isAuthorizedUser === 'boolean' && !isAuthorizedUser && redirectTo) { + void navigate(redirectTo); + } + }, [isAuthorizedUser, redirectTo]); + + // wait for promise to resolve + if (typeof isAuthorizedUser === 'boolean' && !isAuthorizedUser && fallback) { + return <>{fallback}; + } + + if (isAuthorizedUser) { + return <>{children}; + } + + return null; +}; + +export function withGate

(Component: ComponentType

, gateProps: GateProps): React.ComponentType

{ + const displayName = Component.displayName || Component.name || 'Component'; + const HOC = (props: P) => { + return ( + + + + ); + }; + + HOC.displayName = `withGate(${displayName})`; + + return HOC; +} diff --git a/packages/clerk-js/src/ui-retheme/common/InfiniteListSpinner.tsx b/packages/clerk-js/src/ui-retheme/common/InfiniteListSpinner.tsx new file mode 100644 index 0000000000..1a359c4ebe --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/InfiniteListSpinner.tsx @@ -0,0 +1,31 @@ +import { forwardRef } from 'react'; + +import { Box, Spinner } from '../customizables'; + +export const InfiniteListSpinner = forwardRef((_, ref) => { + return ( + ({ + width: '100%', + height: t.space.$12, + position: 'relative', + })} + > + + + + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/common/NotificationCountBadge.tsx b/packages/clerk-js/src/ui-retheme/common/NotificationCountBadge.tsx new file mode 100644 index 0000000000..2bda7db3c2 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/NotificationCountBadge.tsx @@ -0,0 +1,38 @@ +import { Box, NotificationBadge } from '../customizables'; +import { useDelayedVisibility, usePrefersReducedMotion } from '../hooks'; +import type { ThemableCssProp } from '../styledSystem'; +import { animations } from '../styledSystem'; + +export const NotificationCountBadge = ({ + notificationCount, + containerSx, +}: { + notificationCount: number; + containerSx?: ThemableCssProp; +}) => { + const prefersReducedMotion = usePrefersReducedMotion(); + const showNotification = useDelayedVisibility(notificationCount > 0, 350) || false; + + const enterExitAnimation: ThemableCssProp = t => ({ + animation: prefersReducedMotion + ? 'none' + : `${notificationCount ? animations.notificationAnimation : animations.outAnimation} ${ + t.transitionDuration.$textField + } ${t.transitionTiming.$slowBezier} 0s 1 normal forwards`, + }); + + return ( + ({ + position: 'relative', + width: t.sizes.$4, + height: t.sizes.$4, + }), + containerSx, + ]} + > + {showNotification && {notificationCount}} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/PrintableComponent.tsx b/packages/clerk-js/src/ui-retheme/common/PrintableComponent.tsx new file mode 100644 index 0000000000..cc8154e234 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/PrintableComponent.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +type OnPrintCallback = () => void; +type UsePrintableReturn = { + print: () => void; + printableProps: { onPrint: (cb: OnPrintCallback) => void }; +}; + +export const usePrintable = (): UsePrintableReturn => { + const callbacks: OnPrintCallback[] = []; + const onPrint = (cb: OnPrintCallback) => callbacks.push(cb); + const print = () => callbacks.forEach(cb => cb()); + return { print, printableProps: { onPrint } }; +}; + +export const PrintableComponent = (props: UsePrintableReturn['printableProps'] & React.PropsWithChildren) => { + const { children, onPrint } = props; + const ref = React.useRef(null); + + onPrint(() => { + printContentsOfElementViaIFrame(ref); + }); + + return ( +

+ {children} +
+ ); +}; + +const copyStyles = (iframe: HTMLIFrameElement, selector = '[data-emotion=cl-internal]') => { + if (!iframe.contentDocument) { + return; + } + const allStyleText = [...document.head.querySelectorAll(selector)].map(a => a.innerHTML).join('\n'); + const styleEl = iframe.contentDocument.createElement('style'); + styleEl.innerHTML = allStyleText; + iframe.contentDocument.head.prepend(styleEl); +}; + +const setPrintingStyles = (iframe: HTMLIFrameElement) => { + if (!iframe.contentDocument) { + return; + } + // A web-safe font that's universally supported + iframe.contentDocument.body.style.fontFamily = 'Arial'; + // Make the printing dialog display the background colors by default + iframe.contentDocument.body.style.cssText = `* {\n-webkit-print-color-adjust: exact !important;\ncolor-adjust: exact !important;\nprint-color-adjust: exact !important;\n}`; +}; + +const printContentsOfElementViaIFrame = (elementRef: React.MutableRefObject) => { + const content = elementRef.current; + if (!content) { + return; + } + + const frame = document.createElement('iframe'); + frame.style.position = 'fixed'; + frame.style.right = '-2000px'; + frame.style.bottom = '-2000px'; + // frame.style.width = '500px'; + // frame.style.height = '500px'; + // frame.style.border = '0px'; + + frame.onload = () => { + copyStyles(frame); + setPrintingStyles(frame); + if (frame.contentDocument && frame.contentWindow) { + frame.contentDocument.body.innerHTML = content.innerHTML; + frame.contentWindow.print(); + } + }; + + // TODO: Cleaning this iframe is not always possible because + // .print() will not block. Leaving this iframe inside the DOM + // shouldn't be an issue, but is there any reliable way to remove it? + window.document.body.appendChild(frame); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/QRCode.tsx b/packages/clerk-js/src/ui-retheme/common/QRCode.tsx new file mode 100644 index 0000000000..89b1b3a3c6 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/QRCode.tsx @@ -0,0 +1,26 @@ +import { QRCodeSVG } from 'qrcode.react'; + +import { descriptors, Flex } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; + +type QRCodeProps = PropsOfComponent & { url: string; size?: number }; + +export const QRCode = (props: QRCodeProps) => { + const { size = 200, url, ...rest } = props; + return ( + + ({ backgroundColor: 'white', padding: t.space.$2x5 })} + > + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/RemoveResourcePage.tsx b/packages/clerk-js/src/ui-retheme/common/RemoveResourcePage.tsx new file mode 100644 index 0000000000..3eb81b00b2 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/RemoveResourcePage.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { Text } from '../customizables'; +import { ContentPage, Form, FormButtons, SuccessPage, useCardState, withCardStateProvider } from '../elements'; +import type { LocalizationKey } from '../localization'; +import { handleError } from '../utils'; +import { useWizard, Wizard } from './Wizard'; + +type RemovePageProps = { + title: LocalizationKey; + breadcrumbTitle?: LocalizationKey; + messageLine1: LocalizationKey; + messageLine2: LocalizationKey; + successMessage: LocalizationKey; + deleteResource: () => Promise; + Breadcrumbs: React.ComponentType | null; +}; + +export const RemoveResourcePage = withCardStateProvider((props: RemovePageProps) => { + const { title, messageLine1, messageLine2, breadcrumbTitle, successMessage, deleteResource } = props; + const wizard = useWizard(); + const card = useCardState(); + + const handleSubmit = async () => { + try { + await deleteResource().then(() => wizard.nextStep()); + } catch (e) { + handleError(e, [], card.setError); + } + }; + + return ( + + + + + + + + + + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/common/SSOCallback.tsx b/packages/clerk-js/src/ui-retheme/common/SSOCallback.tsx new file mode 100644 index 0000000000..1284fed623 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/SSOCallback.tsx @@ -0,0 +1,41 @@ +import type { HandleOAuthCallbackParams, HandleSamlCallbackParams } from '@clerk/types'; +import React from 'react'; + +import { useCoreClerk } from '../contexts'; +import { Flow } from '../customizables'; +import { Card, CardAlert, LoadingCardContainer, useCardState, withCardStateProvider } from '../elements'; +import { useRouter } from '../router'; +import { handleError } from '../utils'; + +export const SSOCallback = withCardStateProvider(props => { + return ( + + + + ); +}); + +export const SSOCallbackCard = (props: HandleOAuthCallbackParams | HandleSamlCallbackParams) => { + const { handleRedirectCallback } = useCoreClerk(); + const { navigate } = useRouter(); + const card = useCardState(); + + React.useEffect(() => { + let timeoutId: ReturnType; + handleRedirectCallback({ ...props }, navigate).catch(e => { + handleError(e, [], card.setError); + timeoutId = setTimeout(() => void navigate('../'), 4000); + }); + + return () => clearTimeout(timeoutId); + }, [handleError, handleRedirectCallback]); + + return ( + + + {card.error} + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/Wizard.tsx b/packages/clerk-js/src/ui-retheme/common/Wizard.tsx new file mode 100644 index 0000000000..c2c79c5805 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/Wizard.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +type WizardProps = React.PropsWithChildren<{ + step: number; +}>; + +type UseWizardProps = { + defaultStep?: number; + onNextStep?: () => void; +}; + +export const useWizard = (params: UseWizardProps = {}) => { + const { defaultStep = 0, onNextStep } = params; + const [step, setStep] = React.useState(defaultStep); + + const nextStep = React.useCallback(() => { + onNextStep?.(); + setStep((s: number) => s + 1); + }, []); + + const prevStep = React.useCallback(() => setStep(s => s - 1), []); + const goToStep = React.useCallback((i: number) => setStep(i), []); + return { nextStep, prevStep, goToStep, props: { step } }; +}; + +export const Wizard = (props: WizardProps) => { + const { step, children } = props; + return <>{React.Children.toArray(children)[step]}; +}; diff --git a/packages/clerk-js/src/ui-retheme/common/__tests__/redirects.test.ts b/packages/clerk-js/src/ui-retheme/common/__tests__/redirects.test.ts new file mode 100644 index 0000000000..31cff699ef --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/__tests__/redirects.test.ts @@ -0,0 +1,165 @@ +import { buildEmailLinkRedirectUrl, buildSSOCallbackURL } from '../redirects'; + +describe('buildEmailLinkRedirectUrl(routing, baseUrl)', () => { + it('handles empty routing strategy based routing ', function () { + expect(buildEmailLinkRedirectUrl({ path: '', authQueryString: '' } as any, '')).toBe('http://localhost/#/verify'); + }); + + it('returns the magic link redirect url for components using path based routing ', function () { + expect(buildEmailLinkRedirectUrl({ routing: 'path', authQueryString: '' } as any, '')).toBe( + 'http://localhost/verify', + ); + + expect(buildEmailLinkRedirectUrl({ routing: 'path', path: '/sign-in', authQueryString: '' } as any, '')).toBe( + 'http://localhost/sign-in/verify', + ); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'path', + path: '', + authQueryString: 'redirectUrl=https://clerk.com', + } as any, + '', + ), + ).toBe('http://localhost/verify?redirectUrl=https://clerk.com'); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'path', + path: '/sign-in', + authQueryString: 'redirectUrl=https://clerk.com', + } as any, + '', + ), + ).toBe('http://localhost/sign-in/verify?redirectUrl=https://clerk.com'); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'path', + path: '/sign-in', + authQueryString: 'redirectUrl=https://clerk.com', + } as any, + 'https://accounts.clerk.com/sign-in', + ), + ).toBe('http://localhost/sign-in/verify?redirectUrl=https://clerk.com'); + }); + + it('returns the magic link redirect url for components using hash based routing ', function () { + expect( + buildEmailLinkRedirectUrl( + { + routing: 'hash', + authQueryString: '', + } as any, + '', + ), + ).toBe('http://localhost/#/verify'); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'hash', + path: '/sign-in', + authQueryString: null, + } as any, + '', + ), + ).toBe('http://localhost/#/verify'); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'hash', + path: '', + authQueryString: 'redirectUrl=https://clerk.com', + } as any, + '', + ), + ).toBe('http://localhost/#/verify?redirectUrl=https://clerk.com'); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'hash', + path: '/sign-in', + authQueryString: 'redirectUrl=https://clerk.com', + } as any, + '', + ), + ).toBe('http://localhost/#/verify?redirectUrl=https://clerk.com'); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'hash', + path: '/sign-in', + authQueryString: 'redirectUrl=https://clerk.com', + } as any, + 'https://accounts.clerk.com/sign-in', + ), + ).toBe('http://localhost/#/verify?redirectUrl=https://clerk.com'); + }); + + it('returns the magic link redirect url for components using virtual routing ', function () { + expect( + buildEmailLinkRedirectUrl( + { + routing: 'virtual', + authQueryString: 'redirectUrl=https://clerk.com', + } as any, + 'https://accounts.clerk.com/sign-in', + ), + ).toBe('https://accounts.clerk.com/sign-in#/verify?redirectUrl=https://clerk.com'); + + expect( + buildEmailLinkRedirectUrl( + { + routing: 'virtual', + } as any, + 'https://accounts.clerk.com/sign-in', + ), + ).toBe('https://accounts.clerk.com/sign-in#/verify'); + }); +}); + +describe('buildSSOCallbackURL(ctx, baseUrl)', () => { + it('returns the SSO callback URL based on sign in|up component routing or the provided base URL', () => { + // Default callback URLS + expect(buildSSOCallbackURL({}, '')).toBe('http://localhost/#/sso-callback'); + expect(buildSSOCallbackURL({}, 'http://test.host')).toBe('http://localhost/#/sso-callback'); + expect(buildSSOCallbackURL({ authQueryString: 'redirect_url=%2Ffoo' }, 'http://test.host')).toBe( + 'http://localhost/#/sso-callback?redirect_url=%2Ffoo', + ); + + // Components mounted with hash routing + expect(buildSSOCallbackURL({ routing: 'hash' }, 'http://test.host')).toBe('http://localhost/#/sso-callback'); + expect(buildSSOCallbackURL({ routing: 'hash', authQueryString: 'redirect_url=%2Ffoo' }, 'http://test.host')).toBe( + 'http://localhost/#/sso-callback?redirect_url=%2Ffoo', + ); + + // Components mounted with path routing + expect(buildSSOCallbackURL({ routing: 'path', path: 'sign-in' }, 'http://test.host')).toBe( + 'http://localhost/sign-in/sso-callback', + ); + expect( + buildSSOCallbackURL( + { + routing: 'path', + path: 'sign-in', + authQueryString: 'redirect_url=%2Ffoo', + }, + 'http://test.host', + ), + ).toBe('http://localhost/sign-in/sso-callback?redirect_url=%2Ffoo'); + + // Components mounted with virtual routing + expect(buildSSOCallbackURL({ routing: 'virtual' }, 'http://test.host')).toBe('http://test.host/#/sso-callback'); + expect( + buildSSOCallbackURL({ routing: 'virtual', authQueryString: 'redirect_url=%2Ffoo' }, 'http://test.host'), + ).toBe('http://test.host/#/sso-callback?redirect_url=%2Ffoo'); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/common/__tests__/verification.test.ts b/packages/clerk-js/src/ui-retheme/common/__tests__/verification.test.ts new file mode 100644 index 0000000000..6cb7970a46 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/__tests__/verification.test.ts @@ -0,0 +1,66 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; + +import { isVerificationExpiredError, VerificationErrorMessage, verificationErrorMessage } from '../verification'; + +describe('verification utils', () => { + describe('verificationErrorMessage', () => { + it('returns expired error message', () => { + expect( + verificationErrorMessage( + new ClerkAPIResponseError('message', { + data: [{ code: 'verification_expired', message: 'message' }], + status: 400, + }), + ), + ).toEqual(VerificationErrorMessage.CodeExpired); + }); + + it('returns clerk API error message', () => { + const message = 'The message'; + const longMessage = 'The longest message'; + expect( + verificationErrorMessage( + new ClerkAPIResponseError(message, { + data: [{ code: 'whatever', long_message: longMessage, message }], + status: 400, + }), + ), + ).toEqual(longMessage); + + expect( + verificationErrorMessage( + new ClerkAPIResponseError(message, { + data: [{ code: 'whatever', message }], + status: 400, + }), + ), + ).toEqual(message); + }); + + it('falls back to default error message', () => { + expect(verificationErrorMessage(new Error('the error'))).toEqual(VerificationErrorMessage.Incorrect); + }); + }); + + describe('isVerificationExpiredError', () => { + it('returns true for expired code', () => { + const message = 'the message'; + expect( + isVerificationExpiredError( + new ClerkAPIResponseError(message, { + data: [{ code: 'verification_expired', message }], + status: 400, + }).errors[0], + ), + ).toEqual(true); + expect( + isVerificationExpiredError( + new ClerkAPIResponseError(message, { + data: [{ code: 'whatever', message }], + status: 400, + }).errors[0], + ), + ).toEqual(false); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/common/__tests__/withRedirectToHome.test.tsx b/packages/clerk-js/src/ui-retheme/common/__tests__/withRedirectToHome.test.tsx new file mode 100644 index 0000000000..89c253927d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/__tests__/withRedirectToHome.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +import { bindCreateFixtures, render, screen } from '../../../testUtils'; +import { + withRedirectToHomeOrganizationGuard, + withRedirectToHomeSingleSessionGuard, + withRedirectToHomeUserGuard, +} from '../withRedirectToHome'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('withRedirectToHome', () => { + describe('withRedirectToHomeSingleSessionGuard', () => { + it('redirects if a session is present and single session mode is enabled', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({}); + }); + + const WithHOC = withRedirectToHomeSingleSessionGuard(() => <>); + + render(, { wrapper }); + + expect(fixtures.router.navigate).toHaveBeenCalledWith(fixtures.environment.displayConfig.homeUrl); + }); + + it('renders the children if is a session is not present', async () => { + const { wrapper } = await createFixtures(); + + const WithHOC = withRedirectToHomeSingleSessionGuard(() => <>test); + + render(, { wrapper }); + + screen.getByText('test'); + }); + + it('renders the children if multi session mode is enabled and a session is present', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({}); + f.withMultiSessionMode(); + }); + + const WithHOC = withRedirectToHomeSingleSessionGuard(() => <>test); + + render(, { wrapper }); + + screen.getByText('test'); + }); + }); + + describe('redirectToHomeUserGuard', () => { + it('redirects if no user is present', async () => { + const { wrapper, fixtures } = await createFixtures(); + + const WithHOC = withRedirectToHomeUserGuard(() => <>); + + render(, { wrapper }); + + expect(fixtures.router.navigate).toHaveBeenCalledWith(fixtures.environment.displayConfig.homeUrl); + }); + + it('renders the children if is a user is present', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({}); + }); + + const WithHOC = withRedirectToHomeUserGuard(() => <>test); + + render(, { wrapper }); + + screen.getByText('test'); + }); + }); + + describe('withRedirectToHomeOrganizationGuard', () => { + it('redirects if no organization is active', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({}); + f.withOrganizations(); + }); + + const WithHOC = withRedirectToHomeOrganizationGuard(() => <>); + + render(, { wrapper }); + + expect(fixtures.router.navigate).toHaveBeenCalledWith(fixtures.environment.displayConfig.homeUrl); + }); + + it('renders the children if is an organization is active', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ organization_memberships: ['Org1'] }); + f.withOrganizations(); + }); + + const WithHOC = withRedirectToHomeOrganizationGuard(() => <>test); + + render(, { wrapper }); + + screen.getByText('test'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/common/constants.ts b/packages/clerk-js/src/ui-retheme/common/constants.ts new file mode 100644 index 0000000000..a4e9d3165b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/constants.ts @@ -0,0 +1,116 @@ +import { deprecated } from '@clerk/shared/deprecated'; +import type { Attribute, Web3Provider } from '@clerk/types'; + +import type { LocalizationKey } from '../localization/localizationKeys'; +import { localizationKeys } from '../localization/localizationKeys'; + +type FirstFactorConfig = { + label: string | LocalizationKey; + type: string; + placeholder: string | LocalizationKey; + action?: string | LocalizationKey; +}; +const FirstFactorConfigs = Object.freeze({ + email_address_username: { + label: localizationKeys('formFieldLabel__emailAddress_username'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress_username'), + type: 'text', + action: localizationKeys('signIn.start.actionLink__use_email_username'), + }, + email_address: { + label: localizationKeys('formFieldLabel__emailAddress'), + placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), + type: 'email', + action: localizationKeys('signIn.start.actionLink__use_email'), + }, + phone_number: { + label: localizationKeys('formFieldLabel__phoneNumber'), + placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'), + type: 'tel', + action: localizationKeys('signIn.start.actionLink__use_phone'), + }, + username: { + label: localizationKeys('formFieldLabel__username'), + placeholder: localizationKeys('formFieldInputPlaceholder__username'), + type: 'text', + action: localizationKeys('signIn.start.actionLink__use_username'), + }, + default: { + label: '', + placeholder: '', + type: 'text', + action: '', + }, +} as Record); + +export type SignInStartIdentifier = 'email_address' | 'username' | 'phone_number' | 'email_address_username'; +export const groupIdentifiers = (attributes: Attribute[]): SignInStartIdentifier[] => { + let newAttributes: string[] = [...attributes]; + //merge email_address and username attributes + if (['email_address', 'username'].every(r => newAttributes.includes(r))) { + newAttributes = newAttributes.filter(a => !['email_address', 'username'].includes(a)); + newAttributes.unshift('email_address_username'); + } + + return newAttributes as SignInStartIdentifier[]; +}; + +export const getIdentifierControlDisplayValues = ( + identifiers: SignInStartIdentifier[], + identifier: SignInStartIdentifier, +): { currentIdentifier: FirstFactorConfig; nextIdentifier?: FirstFactorConfig } => { + const index = identifiers.indexOf(identifier); + + if (index === -1) { + return { currentIdentifier: { ...FirstFactorConfigs['default'] }, nextIdentifier: undefined }; + } + + return { + currentIdentifier: { ...FirstFactorConfigs[identifier] }, + nextIdentifier: + identifiers.length > 1 ? { ...FirstFactorConfigs[identifiers[(index + 1) % identifiers.length]] } : undefined, + }; +}; + +export const PREFERRED_SIGN_IN_STRATEGIES = Object.freeze({ + Password: 'password', + OTP: 'otp', +}); + +interface Web3ProviderData { + id: string; + name: string; +} + +type Web3Providers = { + [key in Web3Provider]: Web3ProviderData; +}; + +export const WEB3_PROVIDERS: Web3Providers = Object.freeze({ + metamask: { + id: 'metamask', + name: 'MetaMask', + }, +}); + +export function getWeb3ProviderData(name: Web3Provider): Web3ProviderData | undefined | null { + return WEB3_PROVIDERS[name]; +} + +/** + * Returns the URL for a static SVG image + * using the old images.clerk.com service + * @deprecated In favor of iconImageUrl + */ +export function svgUrl(id: string): string { + deprecated('svgUrl', 'Use `iconImageUrl` instead'); + return `https://images.clerk.com/static/${id}.svg`; +} + +/** + * Returns the URL for a static SVG image + * using the new img.clerk.com service + */ +export function iconImageUrl(id: string): string { + return `https://img.clerk.com/static/${id}.svg`; +} diff --git a/packages/clerk-js/src/ui-retheme/common/forms.ts b/packages/clerk-js/src/ui-retheme/common/forms.ts new file mode 100644 index 0000000000..c750804ce5 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/forms.ts @@ -0,0 +1,46 @@ +import React from 'react'; + +export interface FieldState { + name: string; + required?: boolean; + value: T; + setValue: React.Dispatch>; + error: string | undefined; + setError: React.Dispatch>; +} + +export const buildRequest = (fieldStates: Array>): Record => { + const request: { [x: string]: any } = {}; + fieldStates.forEach(x => { + request[x.name] = x.value; + }); + return request; +}; + +export const useFieldState = (name: string, initialState: T): FieldState => { + const [value, setValue] = React.useState(initialState); + const [error, setError] = React.useState(undefined); + + return { + name, + value, + setValue, + error, + setError, + }; +}; + +// TODO: Replace origin useFieldState with this one +export const useFieldStateV2 = (name: string, required: boolean, initialState: T): FieldState => { + const [value, setValue] = React.useState(initialState); + const [error, setError] = React.useState(undefined); + + return { + name, + required, + value, + setValue, + error, + setError, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/common/index.ts b/packages/clerk-js/src/ui-retheme/common/index.ts new file mode 100644 index 0000000000..b08b98ac79 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/index.ts @@ -0,0 +1,19 @@ +export * from './BlockButtons'; +export * from './constants'; +export * from './CalloutWithAction'; +export * from './forms'; +export * from './Gate'; +export * from './InfiniteListSpinner'; +export * from './redirects'; +export * from './verification'; +export * from './withRedirectToHome'; +export * from './SSOCallback'; +export * from './EmailLinkVerify'; +export * from './EmailLinkStatusCard'; +export * from './Wizard'; +export * from './RemoveResourcePage'; +export * from './PrintableComponent'; +export * from './NotificationCountBadge'; +export * from './RemoveResourcePage'; +export * from './withOrganizationsEnabledGuard'; +export * from './QRCode'; diff --git a/packages/clerk-js/src/ui-retheme/common/redirects.ts b/packages/clerk-js/src/ui-retheme/common/redirects.ts new file mode 100644 index 0000000000..c5bf2858cf --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/redirects.ts @@ -0,0 +1,76 @@ +import { buildURL } from '../../utils/url'; +import type { SignInContextType, SignUpContextType, UserProfileContextType } from './../contexts'; + +const SSO_CALLBACK_PATH_ROUTE = '/sso-callback'; +const MAGIC_LINK_VERIFY_PATH_ROUTE = '/verify'; + +export function buildEmailLinkRedirectUrl( + ctx: SignInContextType | SignUpContextType | UserProfileContextType, + baseUrl: string | undefined = '', +): string { + const { routing, authQueryString, path } = ctx; + return buildRedirectUrl({ + routing, + baseUrl, + authQueryString, + path, + endpoint: MAGIC_LINK_VERIFY_PATH_ROUTE, + }); +} + +export function buildSSOCallbackURL( + ctx: Partial, + baseUrl: string | undefined = '', +): string { + const { routing, authQueryString, path } = ctx; + return buildRedirectUrl({ + routing, + baseUrl, + authQueryString, + path, + endpoint: SSO_CALLBACK_PATH_ROUTE, + }); +} + +type AuthQueryString = string | null | undefined; +type BuildRedirectUrlParams = { + routing: string | undefined; + authQueryString: AuthQueryString; + baseUrl: string; + path: string | undefined; + endpoint: string; +}; + +const buildRedirectUrl = ({ routing, authQueryString, baseUrl, path, endpoint }: BuildRedirectUrlParams): string => { + if (!routing || routing === 'hash') { + return buildHashBasedUrl(authQueryString, endpoint); + } + + if (routing === 'path') { + return buildPathBasedUrl(path || '', authQueryString, endpoint); + } + + return buildVirtualBasedUrl(baseUrl || '', authQueryString, endpoint); +}; + +const buildHashBasedUrl = (authQueryString: AuthQueryString, endpoint: string): string => { + // Strip hash to get the URL where we're mounted + const hash = endpoint + (authQueryString ? `?${authQueryString}` : ''); + return buildURL({ hash }, { stringify: true }); +}; + +const buildPathBasedUrl = (path: string, authQueryString: AuthQueryString, endpoint: string): string => { + const searchArg = authQueryString ? { search: '?' + authQueryString } : {}; + return buildURL( + { + pathname: path + endpoint, + ...searchArg, + }, + { stringify: true }, + ); +}; + +const buildVirtualBasedUrl = (base: string, authQueryString: AuthQueryString, endpoint: string): string => { + const hash = endpoint + (authQueryString ? `?${authQueryString}` : ''); + return buildURL({ base, hash }, { stringify: true }); +}; diff --git a/packages/clerk-js/src/ui-retheme/common/verification.ts b/packages/clerk-js/src/ui-retheme/common/verification.ts new file mode 100644 index 0000000000..3bad86c501 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/verification.ts @@ -0,0 +1,23 @@ +import type { ClerkAPIError } from '@clerk/types'; + +import { getClerkAPIErrorMessage, getGlobalError } from '../utils'; + +export const VerificationErrorMessage = { + Incorrect: 'Incorrect, try again', + CodeExpired: 'The code has expired. Resend a new one.', +}; + +export function verificationErrorMessage(err: Error): string { + const globalErr = getGlobalError(err); + if (!globalErr) { + return VerificationErrorMessage.Incorrect; + } + if (isVerificationExpiredError(globalErr)) { + return VerificationErrorMessage.CodeExpired; + } + return getClerkAPIErrorMessage(globalErr); +} + +export function isVerificationExpiredError(err: ClerkAPIError): boolean { + return err.code === 'verification_expired'; +} diff --git a/packages/clerk-js/src/ui-retheme/common/withOrganizationsEnabledGuard.tsx b/packages/clerk-js/src/ui-retheme/common/withOrganizationsEnabledGuard.tsx new file mode 100644 index 0000000000..72b8c66046 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/withOrganizationsEnabledGuard.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { useEnvironment } from '../contexts'; +import { useRouter } from '../router'; + +export function withOrganizationsEnabledGuard

( + WrappedComponent: React.ComponentType

, + name: string, + options: { mode: 'redirect' | 'hide' }, +): React.ComponentType

{ + const Hoc = (props: P) => { + const { navigate } = useRouter(); + const { organizationSettings, displayConfig } = useEnvironment(); + + React.useEffect(() => { + if (options.mode === 'redirect' && !organizationSettings.enabled) { + void navigate(displayConfig.homeUrl); + } + }, []); + + if (options.mode === 'hide' && !organizationSettings.enabled) { + return null; + } + + return ; + }; + Hoc.displayName = name; + return Hoc; +} diff --git a/packages/clerk-js/src/ui-retheme/common/withRedirectToHome.tsx b/packages/clerk-js/src/ui-retheme/common/withRedirectToHome.tsx new file mode 100644 index 0000000000..5fd43b8e2e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/common/withRedirectToHome.tsx @@ -0,0 +1,60 @@ +import type { ComponentType } from 'react'; +import React from 'react'; + +import { warnings } from '../../core/warnings'; +import type { AvailableComponentProps } from '../../ui/types'; +import type { ComponentGuard } from '../../utils'; +import { noOrganizationExists, noUserExists, sessionExistsAndSingleSessionModeEnabled } from '../../utils'; +import { useCoreClerk, useEnvironment, useOptions } from '../contexts'; +import { useRouter } from '../router'; + +function withRedirectToHome

( + Component: ComponentType

, + condition: ComponentGuard, + warning?: string, +): (props: P) => null | JSX.Element { + const displayName = Component.displayName || Component.name || 'Component'; + Component.displayName = displayName; + + const HOC = (props: P) => { + const { navigate } = useRouter(); + const clerk = useCoreClerk(); + const environment = useEnvironment(); + const options = useOptions(); + + const shouldRedirect = condition(clerk, environment, options); + React.useEffect(() => { + if (shouldRedirect) { + if (warning && environment.displayConfig.instanceEnvironmentType === 'development') { + console.info(warning); + } + // TODO: Fix this properly + // eslint-disable-next-line @typescript-eslint/no-floating-promises + navigate(environment.displayConfig.homeUrl); + } + }, []); + + if (shouldRedirect) { + return null; + } + + return ; + }; + + HOC.displayName = `withRedirectToHome(${displayName})`; + + return HOC; +} + +export const withRedirectToHomeSingleSessionGuard =

(Component: ComponentType

) => + withRedirectToHome( + Component, + sessionExistsAndSingleSessionModeEnabled, + warnings.cannotRenderComponentWhenSessionExists, + ); + +export const withRedirectToHomeUserGuard =

(Component: ComponentType

) => + withRedirectToHome(Component, noUserExists, warnings.cannotRenderComponentWhenUserDoesNotExist); + +export const withRedirectToHomeOrganizationGuard =

(Component: ComponentType

) => + withRedirectToHome(Component, noOrganizationExists, warnings.cannotRenderComponentWhenOrgDoesNotExist); diff --git a/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganization.tsx b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganization.tsx new file mode 100644 index 0000000000..69c87f8753 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganization.tsx @@ -0,0 +1,58 @@ +import type { CreateOrganizationProps } from '@clerk/types'; + +import { withOrganizationsEnabledGuard } from '../../common'; +import { ComponentContext, withCoreUserGuard } from '../../contexts'; +import { Flow } from '../../customizables'; +import { ProfileCard, ProfileCardContent, withCardStateProvider } from '../../elements'; +import { Route, Switch } from '../../router'; +import type { CreateOrganizationCtx } from '../../types'; +import { CreateOrganizationPage } from './CreateOrganizationPage'; + +const _CreateOrganization = () => { + return ( + + + + + + + + + + ); +}; + +const AuthenticatedRoutes = withCoreUserGuard(() => { + return ( + ({ width: t.sizes.$120 })}> + + + + + ); +}); + +export const CreateOrganization = withOrganizationsEnabledGuard( + withCardStateProvider(_CreateOrganization), + 'CreateOrganization', + { mode: 'redirect' }, +); + +export const CreateOrganizationModal = (props: CreateOrganizationProps): JSX.Element => { + const createOrganizationProps: CreateOrganizationCtx = { + ...props, + routing: 'virtual', + componentName: 'CreateOrganization', + mode: 'modal', + }; + + return ( + + +

+ +
+ + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganizationForm.tsx b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganizationForm.tsx new file mode 100644 index 0000000000..c5cbfceef8 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganizationForm.tsx @@ -0,0 +1,212 @@ +import type { OrganizationResource } from '@clerk/types'; +import React from 'react'; + +import { useWizard, Wizard } from '../../common'; +import { useCoreOrganization, useCoreOrganizationList } from '../../contexts'; +import { Icon } from '../../customizables'; +import { ContentPage, Form, FormButtonContainer, IconButton, SuccessPage, useCardState } from '../../elements'; +import { QuestionMark, Upload } from '../../icons'; +import type { LocalizationKey } from '../../localization'; +import { localizationKeys } from '../../localization'; +import { colors, createSlug, handleError, useFormControl } from '../../utils'; +import { InviteMembersForm } from '../OrganizationProfile/InviteMembersForm'; +import { InvitationsSentMessage } from '../OrganizationProfile/InviteMembersPage'; +import { OrganizationProfileAvatarUploader } from '../OrganizationProfile/OrganizationProfileAvatarUploader'; + +type CreateOrganizationFormProps = { + skipInvitationScreen: boolean; + navigateAfterCreateOrganization: (organization: OrganizationResource) => Promise; + onCancel?: () => void; + onComplete?: () => void; + flow: 'default' | 'organizationList'; + startPage: { + headerTitle: LocalizationKey; + headerSubtitle?: LocalizationKey; + }; +}; + +export const CreateOrganizationForm = (props: CreateOrganizationFormProps) => { + const card = useCardState(); + const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); + + const lastCreatedOrganizationRef = React.useRef(null); + const { createOrganization, isLoaded, setActive } = useCoreOrganizationList(); + const { organization } = useCoreOrganization(); + const [file, setFile] = React.useState(); + + const nameField = useFormControl('name', '', { + type: 'text', + label: localizationKeys('formFieldLabel__organizationName'), + placeholder: localizationKeys('formFieldInputPlaceholder__organizationName'), + }); + + const slugField = useFormControl('slug', '', { + type: 'text', + label: localizationKeys('formFieldLabel__organizationSlug'), + placeholder: localizationKeys('formFieldInputPlaceholder__organizationSlug'), + }); + + const dataChanged = !!nameField.value; + const canSubmit = dataChanged; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) { + return; + } + + if (!isLoaded) { + return; + } + + try { + const organization = await createOrganization({ name: nameField.value, slug: slugField.value }); + if (file) { + await organization.setLogo({ file }); + } + + lastCreatedOrganizationRef.current = organization; + await setActive({ organization }); + + if (props.skipInvitationScreen ?? organization.maxAllowedMemberships === 1) { + return completeFlow(); + } + + wizard.nextStep(); + } catch (err) { + handleError(err, [nameField, slugField], card.setError); + } + }; + + const completeFlow = () => { + // We are confident that lastCreatedOrganizationRef.current will never be null + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + void props.navigateAfterCreateOrganization(lastCreatedOrganizationRef.current!); + + props.onComplete?.(); + }; + + const onAvatarRemove = () => { + card.setIdle(); + return setFile(null); + }; + + const onChangeName = (event: React.ChangeEvent) => { + nameField.setValue(event.target.value); + updateSlugField(createSlug(event.target.value)); + }; + + const onChangeSlug = (event: React.ChangeEvent) => { + updateSlugField(event.target.value); + }; + + const updateSlugField = (val: string) => { + slugField.setValue(val); + }; + + const headerTitleTextVariant = props.flow === 'organizationList' ? 'xlargeMedium' : undefined; + const headerSubtitleTextVariant = props.flow === 'organizationList' ? 'headingRegularRegular' : undefined; + + return ( + + ({ minHeight: t.sizes.$60 })} + > + + await setFile(file)} + onAvatarRemove={file ? onAvatarRemove : null} + avatarPreviewPlaceholder={ + ({ + transitionDuration: theme.transitionDuration.$controls, + })} + /> + } + sx={theme => ({ + width: theme.sizes.$11, + height: theme.sizes.$11, + borderRadius: theme.radii.$md, + backgroundColor: theme.colors.$avatarBackground, + ':hover': { + backgroundColor: colors.makeTransparent(theme.colors.$avatarBackground, 0.2), + svg: { + transform: 'scale(1.2)', + }, + }, + })} + /> + } + /> + + + + + + + + + {props.onCancel && ( + + )} + + + + ({ minHeight: t.sizes.$60 })} + > + {organization && ( + + )} + + } + sx={t => ({ minHeight: t.sizes.$60 })} + onFinish={completeFlow} + /> + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganizationPage.tsx b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganizationPage.tsx new file mode 100644 index 0000000000..71f3f17cfc --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/CreateOrganizationPage.tsx @@ -0,0 +1,27 @@ +import { useCoreClerk, useCreateOrganizationContext } from '../../contexts'; +import { localizationKeys } from '../../customizables'; +import { withCardStateProvider } from '../../elements'; +import { CreateOrganizationForm } from './CreateOrganizationForm'; + +export const CreateOrganizationPage = withCardStateProvider(() => { + const title = localizationKeys('createOrganization.title'); + const { closeCreateOrganization } = useCoreClerk(); + + const { mode, navigateAfterCreateOrganization, skipInvitationScreen } = useCreateOrganizationContext(); + + return ( + { + if (mode === 'modal') { + closeCreateOrganization(); + } + }} + /> + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/components/CreateOrganization/__tests__/CreateOrganization.test.tsx b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/__tests__/CreateOrganization.test.tsx new file mode 100644 index 0000000000..ea1c6f5bc2 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/__tests__/CreateOrganization.test.tsx @@ -0,0 +1,214 @@ +import type { OrganizationResource } from '@clerk/types'; +import { describe, jest } from '@jest/globals'; +import { waitFor } from '@testing-library/dom'; + +import { render } from '../../../../testUtils'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { CreateOrganization } from '../CreateOrganization'; + +const { createFixtures } = bindCreateFixtures('CreateOrganization'); + +export type FakeOrganizationParams = { + id: string; + createdAt?: Date; + imageUrl?: string; + logoUrl?: string; + slug: string; + name: string; + membersCount: number; + pendingInvitationsCount: number; + adminDeleteEnabled: boolean; + maxAllowedMemberships: number; +}; + +export const createFakeOrganization = (params: FakeOrganizationParams): OrganizationResource => { + return { + logoUrl: null, + pathRoot: '', + id: params.id, + name: params.name, + slug: params.slug, + hasImage: !!params.imageUrl, + imageUrl: params.imageUrl || '', + membersCount: params.membersCount, + pendingInvitationsCount: params.pendingInvitationsCount, + publicMetadata: {}, + adminDeleteEnabled: params.adminDeleteEnabled, + maxAllowedMemberships: params?.maxAllowedMemberships, + createdAt: params?.createdAt || new Date(), + updatedAt: new Date(), + update: jest.fn() as any, + getMemberships: jest.fn() as any, + getPendingInvitations: jest.fn() as any, + addMember: jest.fn() as any, + inviteMember: jest.fn() as any, + inviteMembers: jest.fn() as any, + updateMember: jest.fn() as any, + removeMember: jest.fn() as any, + createDomain: jest.fn() as any, + getDomain: jest.fn() as any, + getDomains: jest.fn() as any, + getMembershipRequests: jest.fn() as any, + destroy: jest.fn() as any, + setLogo: jest.fn() as any, + reload: jest.fn() as any, + }; +}; + +const getCreatedOrg = (params: Partial) => + createFakeOrganization({ + id: '1', + adminDeleteEnabled: false, + maxAllowedMemberships: 1, + membersCount: 1, + name: 'new org', + pendingInvitationsCount: 0, + slug: 'new-org', + ...params, + }); + +describe('CreateOrganization', () => { + it('renders component', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + const { getByText } = render(, { wrapper }); + expect(getByText('Create Organization')).toBeInTheDocument(); + }); + + it('skips invitation screen', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 3, + }), + ), + ); + + props.setProps({ skipInvitationScreen: true }); + const { getByRole, userEvent, getByLabelText, queryByText } = render(, { + wrapper, + }); + await userEvent.type(getByLabelText(/Organization name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite members/i)).not.toBeInTheDocument(); + }); + }); + + it('always visit invitation screen', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 1, + }), + ), + ); + + props.setProps({ skipInvitationScreen: false }); + const { getByRole, userEvent, getByLabelText, queryByText } = render(, { + wrapper, + }); + await userEvent.type(getByLabelText(/Organization name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite members/i)).toBeInTheDocument(); + }); + }); + + it('auto skip invitation screen', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + fixtures.clerk.createOrganization.mockReturnValue( + Promise.resolve( + getCreatedOrg({ + maxAllowedMemberships: 1, + }), + ), + ); + + const { getByRole, userEvent, getByLabelText, queryByText } = render(, { + wrapper, + }); + await userEvent.type(getByLabelText(/Organization name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + await waitFor(() => { + expect(queryByText(/Invite members/i)).not.toBeInTheDocument(); + }); + }); + + describe('navigation', () => { + it('constructs afterCreateOrganizationUrl from function', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + const createdOrg = getCreatedOrg({ + maxAllowedMemberships: 1, + }); + + fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg)); + + props.setProps({ afterCreateOrganizationUrl: org => `/org/${org.id}`, skipInvitationScreen: true }); + const { getByRole, userEvent, getByLabelText } = render(, { + wrapper, + }); + await userEvent.type(getByLabelText(/Organization name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.id}`); + }); + + it('constructs afterCreateOrganizationUrl from `:slug` ', async () => { + const { wrapper, fixtures, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + const createdOrg = getCreatedOrg({ + maxAllowedMemberships: 1, + }); + + fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg)); + + props.setProps({ afterCreateOrganizationUrl: '/org/:slug', skipInvitationScreen: true }); + const { getByRole, userEvent, getByLabelText } = render(, { + wrapper, + }); + await userEvent.type(getByLabelText(/Organization name/i), 'new org'); + await userEvent.click(getByRole('button', { name: /create organization/i })); + + expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.slug}`); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/components/CreateOrganization/index.tsx b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/index.tsx new file mode 100644 index 0000000000..3ce3f438c9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/CreateOrganization/index.tsx @@ -0,0 +1 @@ +export * from './CreateOrganization'; diff --git a/packages/clerk-js/src/ui-retheme/components/ImpersonationFab/ImpersonationFab.tsx b/packages/clerk-js/src/ui-retheme/components/ImpersonationFab/ImpersonationFab.tsx new file mode 100644 index 0000000000..1478bb9a74 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/ImpersonationFab/ImpersonationFab.tsx @@ -0,0 +1,240 @@ +import type { PointerEventHandler } from 'react'; +import React, { useEffect, useRef } from 'react'; + +import { getFullName, getIdentifier } from '../../../utils/user'; +import { useCoreClerk, useCoreSession, withCoreUserGuard } from '../../contexts'; +import type { LocalizationKey } from '../../customizables'; +import { + Col, + descriptors, + Flex, + Icon, + Link, + localizationKeys, + Text, + useAppearance, + useLocalizations, +} from '../../customizables'; +import { Portal } from '../../elements/Portal'; +import { Eye } from '../../icons'; +import type { PropsOfComponent } from '../../styledSystem'; +import { InternalThemeProvider, mqu } from '../../styledSystem'; + +type EyeCircleProps = PropsOfComponent & { + width: string; + height: string; +}; + +const EyeCircle = ({ width, height, ...props }: EyeCircleProps) => { + const { sx, ...rest } = props; + return ( + ({ + width, + height, + backgroundColor: t.colors.$danger500, + borderRadius: t.radii.$circle, + }), + sx, + ]} + {...rest} + > + ({ + color: t.colors.$white, + })} + size={'lg'} + /> + + ); +}; + +type FabContentProps = { title: LocalizationKey; signOutText: LocalizationKey }; + +const FabContent = ({ title, signOutText }: FabContentProps) => { + const session = useCoreSession(); + const { signOut } = useCoreClerk(); + + return ( + ({ + width: '100%', + paddingLeft: t.sizes.$4, + paddingRight: t.sizes.$6, + whiteSpace: 'nowrap', + })} + > + + ({ + alignSelf: 'flex-start', + color: t.colors.$primary500, + ':hover': { + cursor: 'pointer', + }, + })} + localizationKey={signOutText} + onClick={async () => { + await signOut({ sessionId: session.id }); + }} + /> + + ); +}; + +const _ImpersonationFab = () => { + const session = useCoreSession(); + const { t } = useLocalizations(); + const { parsedInternalTheme } = useAppearance(); + const containerRef = useRef(null); + const actor = session?.actor; + const isImpersonating = !!actor; + + //essentials for calcs + const eyeWidth = parsedInternalTheme.sizes.$16; + const eyeHeight = eyeWidth; + const topProperty = '--cl-impersonation-fab-top'; + const rightProperty = '--cl-impersonation-fab-right'; + const defaultTop = 109; + const defaultRight = 23; + + const handleResize = () => { + const current = containerRef.current; + if (!current) { + return; + } + + const offsetRight = window.innerWidth - current.offsetLeft - current.offsetWidth; + const offsetBottom = window.innerHeight - current.offsetTop - current.offsetHeight; + + const outsideViewport = [current.offsetLeft, offsetRight, current.offsetTop, offsetBottom].some(o => o < 0); + + if (outsideViewport) { + document.documentElement.style.setProperty(rightProperty, `${defaultRight}px`); + document.documentElement.style.setProperty(topProperty, `${defaultTop}px`); + } + }; + + const onPointerDown: PointerEventHandler = () => { + window.addEventListener('pointermove', onPointerMove); + window.addEventListener( + 'pointerup', + () => { + window.removeEventListener('pointermove', onPointerMove); + handleResize(); + }, + { once: true }, + ); + }; + + const onPointerMove = React.useCallback((e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + const current = containerRef.current; + if (!current) { + return; + } + const rightOffestBasedOnViewportAndContent = `${ + window.innerWidth - current.offsetLeft - current.offsetWidth - e.movementX + }px`; + document.documentElement.style.setProperty(rightProperty, rightOffestBasedOnViewportAndContent); + document.documentElement.style.setProperty(topProperty, `${current.offsetTop - -e.movementY}px`); + }, []); + + const repositionFabOnResize = () => { + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }; + + useEffect(repositionFabOnResize, []); + + if (!isImpersonating || !session.user) { + return null; + } + + const title = localizationKeys('impersonationFab.title', { + identifier: getFullName(session.user) || getIdentifier(session.user), + }); + const titleLength = t(title).length; + + return ( + + ({ + touchAction: 'none', //for drag to work on mobile consistently + position: 'fixed', + overflow: 'hidden', + top: `var(${topProperty}, ${defaultTop}px)`, + right: `var(${rightProperty}, ${defaultRight}px)`, + zIndex: t.zIndices.$fab, + boxShadow: t.shadows.$fabShadow, + borderRadius: t.radii.$halfHeight, //to match the circular eye perfectly + backgroundColor: t.colors.$white, + fontFamily: t.fonts.$main, + ':hover': { + cursor: 'grab', + }, + ':hover #cl-impersonationText': { + transition: `max-width ${t.transitionDuration.$slowest} ease, opacity ${t.transitionDuration.$slower} ease ${t.transitionDuration.$slowest}`, + maxWidth: `min(calc(50vw - ${eyeWidth} - 2 * ${defaultRight}px), ${titleLength}ch)`, + [mqu.md]: { + maxWidth: `min(calc(100vw - ${eyeWidth} - 2 * ${defaultRight}px), ${titleLength}ch)`, + }, + opacity: 1, + }, + ':hover #cl-impersonationEye': { + transform: 'rotate(-180deg)', + }, + })} + > + ({ + transition: `transform ${t.transitionDuration.$slowest} ease`, + })} + /> + + ({ + transition: `max-width ${t.transitionDuration.$slowest} ease, opacity ${t.transitionDuration.$fast} ease`, + maxWidth: '0px', + opacity: 0, + })} + > + + + + + ); +}; + +export const ImpersonationFab = withCoreUserGuard(() => ( + + <_ImpersonationFab /> + +)); diff --git a/packages/clerk-js/src/ui-retheme/components/ImpersonationFab/index.ts b/packages/clerk-js/src/ui-retheme/components/ImpersonationFab/index.ts new file mode 100644 index 0000000000..7356c19b22 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/ImpersonationFab/index.ts @@ -0,0 +1 @@ +export * from './ImpersonationFab'; diff --git a/packages/clerk-js/src/ui-retheme/components/OrganizationList/OrganizationList.tsx b/packages/clerk-js/src/ui-retheme/components/OrganizationList/OrganizationList.tsx new file mode 100644 index 0000000000..b36a7bcdd4 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/OrganizationList/OrganizationList.tsx @@ -0,0 +1,25 @@ +import { withOrganizationsEnabledGuard } from '../../common'; +import { withCoreUserGuard } from '../../contexts'; +import { Flow } from '../../customizables'; +import { Route, Switch } from '../../router'; +import { OrganizationListPage } from './OrganizationListPage'; + +const _OrganizationList = () => { + return ( + + + + + + + + + + ); +}; + +const AuthenticatedRoutes = withCoreUserGuard(OrganizationListPage); + +export const OrganizationList = withOrganizationsEnabledGuard(_OrganizationList, 'OrganizationList', { + mode: 'redirect', +}); diff --git a/packages/clerk-js/src/ui-retheme/components/OrganizationList/OrganizationListPage.tsx b/packages/clerk-js/src/ui-retheme/components/OrganizationList/OrganizationListPage.tsx new file mode 100644 index 0000000000..509eb62abd --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/OrganizationList/OrganizationListPage.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; + +import { useCoreOrganizationList, useEnvironment, useOrganizationListContext } from '../../contexts'; +import { Box, Button, Col, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; +import { Card, CardAlert, Divider, Header, useCardState, withCardStateProvider } from '../../elements'; +import { useInView } from '../../hooks'; +import { CreateOrganizationForm } from '../CreateOrganization/CreateOrganizationForm'; +import { PreviewListItems, PreviewListSpinner } from './shared'; +import { InvitationPreview } from './UserInvitationList'; +import { MembershipPreview, PersonalAccountPreview } from './UserMembershipList'; +import { SuggestionPreview } from './UserSuggestionList'; +import { organizationListParams } from './utils'; + +const useCoreOrganizationListInView = () => { + const { userMemberships, userInvitations, userSuggestions } = useCoreOrganizationList(organizationListParams); + + const { ref } = useInView({ + threshold: 0, + onChange: inView => { + if (!inView) { + return; + } + if (userMemberships.hasNextPage) { + userMemberships.fetchNext?.(); + } else if (userInvitations.hasNextPage) { + userInvitations.fetchNext?.(); + } else { + userSuggestions.fetchNext?.(); + } + }, + }); + + return { + userMemberships, + userInvitations, + userSuggestions, + ref, + }; +}; + +export const OrganizationListPage = withCardStateProvider(() => { + const card = useCardState(); + const { userMemberships, userSuggestions, userInvitations } = useCoreOrganizationListInView(); + const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; + const hasAnyData = !!(userMemberships?.count || userInvitations?.count || userSuggestions?.count); + + const { hidePersonal } = useOrganizationListContext(); + + return ( + ({ + padding: `${t.space.$8} ${t.space.$none}`, + })} + gap={6} + > + {card.error} + {isLoading && ( + ({ + height: '100%', + minHeight: t.sizes.$60, + })} + > + + + )} + + {!isLoading && } + + ); +}); + +const OrganizationListFlows = ({ showListInitially }: { showListInitially: boolean }) => { + const environment = useEnvironment(); + const { navigateAfterSelectOrganization, skipInvitationScreen } = useOrganizationListContext(); + const [isCreateOrganizationFlow, setCreateOrganizationFlow] = useState(!showListInitially); + return ( + <> + {!isCreateOrganizationFlow && ( + setCreateOrganizationFlow(true)} /> + )} + + {isCreateOrganizationFlow && ( + ({ + padding: `${t.space.$none} ${t.space.$8}`, + })} + > + + navigateAfterSelectOrganization(org).then(() => setCreateOrganizationFlow(false)) + } + onCancel={ + showListInitially && isCreateOrganizationFlow ? () => setCreateOrganizationFlow(false) : undefined + } + /> + + )} + + ); +}; + +const OrganizationListPageList = (props: { onCreateOrganizationClick: () => void }) => { + const environment = useEnvironment(); + + const { ref, userMemberships, userSuggestions, userInvitations } = useCoreOrganizationListInView(); + const { hidePersonal } = useOrganizationListContext(); + + const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; + const hasNextPage = userMemberships?.hasNextPage || userInvitations?.hasNextPage || userSuggestions?.hasNextPage; + + const handleCreateOrganizationClicked = () => { + props.onCreateOrganizationClick(); + }; + return ( + <> + ({ + padding: `${t.space.$none} ${t.space.$8}`, + })} + > + + + + + + + {(userMemberships.count || 0) > 0 && + userMemberships.data?.map(inv => { + return ( + + ); + })} + + {!userMemberships.hasNextPage && + (userInvitations.count || 0) > 0 && + userInvitations.data?.map(inv => { + return ( + + ); + })} + + {!userMemberships.hasNextPage && + !userInvitations.hasNextPage && + (userSuggestions.count || 0) > 0 && + userSuggestions.data?.map(inv => { + return ( + + ); + })} + + {(hasNextPage || isLoading) && } + + + ({ + padding: `${t.space.$none} ${t.space.$8}`, + })} + /> + + ({ + padding: `${t.space.$none} ${t.space.$8}`, + })} + > + + ); + }), +); +const NotificationCountBadgeSwitcherTrigger = withGate( + () => { + /** + * Prefetch user invitations and suggestions + */ + const { userInvitations, userSuggestions } = useCoreOrganizationList(organizationListParams); + const { organizationSettings } = useEnvironment(); + const isDomainsEnabled = organizationSettings?.domains?.enabled; + const { membershipRequests } = useCoreOrganization({ + membershipRequests: isDomainsEnabled || undefined, + }); + + const notificationCount = + (userInvitations.count || 0) + (userSuggestions.count || 0) + (membershipRequests?.count || 0); + + return ( + ({ + marginLeft: `${t.space.$2}`, + })} + notificationCount={notificationCount} + /> + ); + }, + { + // if the user is not able to accept a request we should not notify them + permission: 'org:sys_memberships:manage', + }, +); diff --git a/packages/clerk-js/src/ui-retheme/components/OrganizationSwitcher/OtherOrganizationActions.tsx b/packages/clerk-js/src/ui-retheme/components/OrganizationSwitcher/OtherOrganizationActions.tsx new file mode 100644 index 0000000000..f2becc256e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/OrganizationSwitcher/OtherOrganizationActions.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { Plus } from '../../../ui/icons'; +import { useCoreUser } from '../../contexts'; +import { descriptors, localizationKeys } from '../../customizables'; +import { Action, SecondaryActions } from '../../elements'; +import { UserInvitationSuggestionList } from './UserInvitationSuggestionList'; +import type { UserMembershipListProps } from './UserMembershipList'; +import { UserMembershipList } from './UserMembershipList'; + +export interface OrganizationActionListProps extends UserMembershipListProps { + onCreateOrganizationClick: React.MouseEventHandler; +} + +const CreateOrganizationButton = ({ + onCreateOrganizationClick, +}: Pick) => { + const user = useCoreUser(); + + if (!user.createOrganizationEnabled) { + return null; + } + + return ( + + ); +}; + +export const OrganizationActionList = (props: OrganizationActionListProps) => { + const { onCreateOrganizationClick, onPersonalWorkspaceClick, onOrganizationClick } = props; + + return ( + <> + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/components/OrganizationSwitcher/UserInvitationSuggestionList.tsx b/packages/clerk-js/src/ui-retheme/components/OrganizationSwitcher/UserInvitationSuggestionList.tsx new file mode 100644 index 0000000000..847ccfea65 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/OrganizationSwitcher/UserInvitationSuggestionList.tsx @@ -0,0 +1,194 @@ +import type { OrganizationSuggestionResource, UserOrganizationInvitationResource } from '@clerk/types'; +import type { PropsWithChildren } from 'react'; + +import { InfiniteListSpinner } from '../../common'; +import { useCoreOrganizationList } from '../../contexts'; +import { Box, Button, descriptors, Flex, localizationKeys, Text } from '../../customizables'; +import { Actions, OrganizationPreview, useCardState, withCardStateProvider } from '../../elements'; +import { useInView } from '../../hooks'; +import type { PropsOfComponent } from '../../styledSystem'; +import { common } from '../../styledSystem'; +import { handleError } from '../../utils'; +import { organizationListParams, populateCacheRemoveItem, populateCacheUpdateItem } from './utils'; + +const useFetchInvitations = () => { + const { userInvitations, userSuggestions } = useCoreOrganizationList(organizationListParams); + + const { ref } = useInView({ + threshold: 0, + onChange: inView => { + if (!inView) { + return; + } + if (userInvitations.hasNextPage) { + userInvitations.fetchNext?.(); + } else { + userSuggestions.fetchNext?.(); + } + }, + }); + + return { + userInvitations, + userSuggestions, + ref, + }; +}; + +const AcceptRejectSuggestionButtons = (props: OrganizationSuggestionResource) => { + const card = useCardState(); + const { userSuggestions } = useCoreOrganizationList({ + userSuggestions: organizationListParams.userSuggestions, + }); + + const handleAccept = () => { + return card + .runAsync(props.accept) + .then(updatedItem => userSuggestions?.setData?.(pages => populateCacheUpdateItem(updatedItem, pages))) + .catch(err => handleError(err, [], card.setError)); + }; + + if (props.status === 'accepted') { + return ( + + ); + } + + return ( + + ); + }), +); diff --git a/packages/clerk-js/src/ui-retheme/components/UserButton/__tests__/UserButton.test.tsx b/packages/clerk-js/src/ui-retheme/components/UserButton/__tests__/UserButton.test.tsx new file mode 100644 index 0000000000..a0228519fa --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/UserButton/__tests__/UserButton.test.tsx @@ -0,0 +1,173 @@ +import { describe } from '@jest/globals'; + +import { render } from '../../../../testUtils'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { UserButton } from '../UserButton'; + +const { createFixtures } = bindCreateFixtures('UserButton'); + +describe('UserButton', () => { + it('renders no button when there is no logged in user', async () => { + const { wrapper } = await createFixtures(); + const { queryByRole } = render(, { wrapper }); + expect(queryByRole('button')).toBeNull(); + }); + + it('renders button when there is a user', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + const { queryByRole } = render(, { wrapper }); + expect(queryByRole('button')).not.toBeNull(); + }); + + it('opens the user button popover when clicked', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ + first_name: 'First', + last_name: 'Last', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user button' })); + expect(getByText('Manage account')).not.toBeNull(); + }); + + it('opens user profile when "Manage account" is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ + first_name: 'First', + last_name: 'Last', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user button' })); + await userEvent.click(getByText('Manage account')); + expect(fixtures.clerk.openUserProfile).toHaveBeenCalled(); + }); + + it('signs out user when "Sign out" is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ + first_name: 'First', + last_name: 'Last', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user button' })); + await userEvent.click(getByText('Sign out')); + expect(fixtures.clerk.signOut).toHaveBeenCalled(); + }); + + it.todo('navigates to sign in url when "Add account" is clicked'); + + describe('Multi Session Popover', () => { + const initConfig = createFixtures.config(f => { + f.withMultiSessionMode(); + f.withUser({ + id: '1', + first_name: 'First1', + last_name: 'Last1', + username: 'username1', + email_addresses: ['test1@clerk.com'], + }); + f.withUser({ + id: '2', + first_name: 'First2', + last_name: 'Last2', + username: 'username2', + email_addresses: ['test2@clerk.com'], + }); + f.withUser({ + id: '3', + first_name: 'First3', + last_name: 'Last3', + username: 'username3', + email_addresses: ['test3@clerk.com'], + }); + }); + + it('renders all sessions', async () => { + const { wrapper } = await createFixtures(initConfig); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user button' })); + expect(getByText('First1 Last1')).toBeDefined(); + expect(getByText('First2 Last2')).toBeDefined(); + expect(getByText('First3 Last3')).toBeDefined(); + }); + + it('changes the active session when clicking another session', async () => { + const { wrapper, fixtures } = await createFixtures(initConfig); + fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve()); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user button' })); + await userEvent.click(getByText('First3 Last3')); + expect(fixtures.clerk.setActive).toHaveBeenCalledWith( + expect.objectContaining({ session: expect.objectContaining({ user: expect.objectContaining({ id: '3' }) }) }), + ); + }); + + it('signs out of the currently active session when clicking "Sign out"', async () => { + const { wrapper, fixtures } = await createFixtures(initConfig); + fixtures.clerk.signOut.mockReturnValueOnce(Promise.resolve()); + const { getByText, getByRole, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: 'Open user button' })); + await userEvent.click(getByText('Sign out')); + expect(fixtures.clerk.signOut).toHaveBeenCalledWith(expect.any(Function), { sessionId: '0' }); + }); + }); + + describe('UserButtonTopLevelIdentifier', () => { + it('gives priority to showing first and last name next to the button over username and email', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withUser({ + first_name: 'TestFirstName', + last_name: 'TestLastName', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + props.setProps({ showName: true }); + const { getByText } = render(, { wrapper }); + expect(getByText('TestFirstName TestLastName')).toBeDefined(); + }); + + it('gives priority to showing username next to the button over email', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withUser({ first_name: '', last_name: '', username: 'username1', email_addresses: ['test@clerk.com'] }); + }); + props.setProps({ showName: true }); + const { getByText } = render(, { wrapper }); + expect(getByText('username1')).toBeDefined(); + }); + + it('shows email next to the button if there is no username or first/last name', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withUser({ first_name: '', last_name: '', username: '', email_addresses: ['test@clerk.com'] }); + }); + props.setProps({ showName: true }); + const { getByText } = render(, { wrapper }); + expect(getByText('test@clerk.com')).toBeDefined(); + }); + + it('does not show an identifier next to the button', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withUser({ + first_name: 'TestFirstName', + last_name: 'TestLastName', + username: 'username1', + email_addresses: ['test@clerk.com'], + }); + }); + props.setProps({ showName: false }); + const { queryByText } = render(, { wrapper }); + expect(queryByText('TestFirstName TestLastName')).toBeNull(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/components/UserButton/index.ts b/packages/clerk-js/src/ui-retheme/components/UserButton/index.ts new file mode 100644 index 0000000000..ab6156ab87 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/UserButton/index.ts @@ -0,0 +1 @@ +export * from './UserButton'; diff --git a/packages/clerk-js/src/ui-retheme/components/UserButton/useMultisessionActions.tsx b/packages/clerk-js/src/ui-retheme/components/UserButton/useMultisessionActions.tsx new file mode 100644 index 0000000000..5c4d8f8e8b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/UserButton/useMultisessionActions.tsx @@ -0,0 +1,91 @@ +import { deprecatedObjectProperty } from '@clerk/shared/deprecated'; +import type { ActiveSessionResource, UserButtonProps, UserResource } from '@clerk/types'; + +import { windowNavigate } from '../../../utils/windowNavigate'; +import { useCoreClerk, useCoreSessionList } from '../../contexts'; +import { useCardState } from '../../elements'; +import { useRouter } from '../../router'; +import { sleep } from '../../utils'; + +type UseMultisessionActionsParams = { + user: UserResource | undefined; + actionCompleteCallback?: () => void; + navigateAfterSignOut?: () => any; + navigateAfterMultiSessionSingleSignOut?: () => any; + navigateAfterSwitchSession?: () => any; + userProfileUrl?: string; + signInUrl?: string; +} & Pick; + +export const useMultisessionActions = (opts: UseMultisessionActionsParams) => { + const { setActive, signOut, openUserProfile } = useCoreClerk(); + const card = useCardState(); + const sessions = useCoreSessionList(); + const { navigate } = useRouter(); + const activeSessions = sessions.filter(s => s.status === 'active') as ActiveSessionResource[]; + const otherSessions = activeSessions.filter(s => s.user?.id !== opts.user?.id); + + const handleSignOutSessionClicked = (session: ActiveSessionResource) => () => { + if (otherSessions.length === 0) { + return signOut(opts.navigateAfterSignOut); + } + return signOut(opts.navigateAfterMultiSessionSingleSignOut, { sessionId: session.id }).finally(() => + card.setIdle(), + ); + }; + + const handleManageAccountClicked = () => { + if (opts.userProfileMode === 'navigation') { + return navigate(opts.userProfileUrl || '').finally(() => { + void (async () => { + await sleep(300); + opts.actionCompleteCallback?.(); + })(); + }); + } + + // The UserButton can also accept an appearance object for the nested UserProfile modal + if (opts.appearance?.userProfile) { + deprecatedObjectProperty( + opts.appearance, + 'userProfile', + 'Use `` instead.', + ); + } + openUserProfile({ + appearance: opts.appearance?.userProfile, + // Prioritize the appearance of `userProfileProps` + ...opts.userProfileProps, + }); + return opts.actionCompleteCallback?.(); + }; + + const handleSignOutAllClicked = () => { + return signOut(opts.navigateAfterSignOut); + }; + + // TODO: Fix this eslint error + + const handleSessionClicked = (session: ActiveSessionResource) => async () => { + card.setLoading(); + return setActive({ session, beforeEmit: opts.navigateAfterSwitchSession }).finally(() => { + card.setIdle(); + opts.actionCompleteCallback?.(); + }); + }; + + const handleAddAccountClicked = () => { + windowNavigate(opts.signInUrl || window.location.href); + return sleep(2000); + }; + + return { + handleSignOutSessionClicked, + handleManageAccountClicked, + handleSignOutAllClicked, + handleSessionClicked, + handleAddAccountClicked, + otherSessions, + activeSessions, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/components/UserProfile/ActiveDevicesSection.tsx b/packages/clerk-js/src/ui-retheme/components/UserProfile/ActiveDevicesSection.tsx new file mode 100644 index 0000000000..10fd409133 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/UserProfile/ActiveDevicesSection.tsx @@ -0,0 +1,169 @@ +import type { SessionWithActivitiesResource } from '@clerk/types'; +import React from 'react'; + +import { useCoreSession, useCoreUser } from '../../contexts'; +import { Badge, Col, descriptors, Flex, Icon, localizationKeys, Text, useLocalizations } from '../../customizables'; +import { FullHeightLoader, ProfileSection } from '../../elements'; +import { DeviceLaptop, DeviceMobile } from '../../icons'; +import { mqu } from '../../styledSystem'; +import { getRelativeToNowDateKey } from '../../utils'; +import { LinkButtonWithDescription } from './LinkButtonWithDescription'; +import { UserProfileAccordion } from './UserProfileAccordion'; +import { currentSessionFirst } from './utils'; + +export const ActiveDevicesSection = () => { + const user = useCoreUser(); + const session = useCoreSession(); + const [sessionsWithActivities, setSessionsWithActivities] = React.useState([]); + + React.useEffect(() => { + void user?.getSessions().then(sa => setSessionsWithActivities(sa)); + }, [user]); + + return ( + + {!sessionsWithActivities.length && } + {!!sessionsWithActivities.length && + sessionsWithActivities.sort(currentSessionFirst(session?.id)).map(sa => ( + + ))} + + ); +}; + +const DeviceAccordion = (props: { session: SessionWithActivitiesResource }) => { + const isCurrent = useCoreSession()?.id === props.session.id; + const revoke = async () => { + if (isCurrent || !props.session) { + return; + } + return props.session.revoke(); + }; + + return ( + } + > + + {isCurrent && ( + + )} + {!isCurrent && ( + + )} + + + ); +}; + +const DeviceInfo = (props: { session: SessionWithActivitiesResource }) => { + const coreSession = useCoreSession(); + const isCurrent = coreSession?.id === props.session.id; + const isCurrentlyImpersonating = !!coreSession.actor; + const isImpersonationSession = !!props.session.actor; + const { city, country, browserName, browserVersion, deviceType, ipAddress, isMobile } = props.session.latestActivity; + const title = deviceType ? deviceType : isMobile ? 'Mobile device' : 'Desktop device'; + const browser = `${browserName || ''} ${browserVersion || ''}`.trim() || 'Web browser'; + const location = [city || '', country || ''].filter(Boolean).join(', ').trim() || null; + const { t } = useLocalizations(); + + return ( + ({ + gap: t.space.$8, + [mqu.xs]: { gap: t.space.$2 }, + })} + > + ({ + padding: `0 ${theme.space.$3}`, + [mqu.sm]: { padding: `0` }, + borderRadius: theme.radii.$md, + })} + > + ({ + '--cl-chassis-bottom': '#444444', + '--cl-chassis-back': '#343434', + '--cl-chassis-screen': '#575757', + '--cl-screen': '#000000', + width: theme.space.$20, + height: theme.space.$20, + [mqu.sm]: { + width: theme.space.$10, + height: theme.space.$10, + }, + })} + /> + + + + {title} + {isCurrent && ( + + )} + {isCurrentlyImpersonating && !isImpersonationSession && ( + + )} + {!isCurrent && isImpersonationSession && ( + + )} + + + {browser} + + + {ipAddress} ({location}) + + + {t(getRelativeToNowDateKey(props.session.lastActiveAt))} + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/components/UserProfile/AddAuthenticatorApp.tsx b/packages/clerk-js/src/ui-retheme/components/UserProfile/AddAuthenticatorApp.tsx new file mode 100644 index 0000000000..fa7cf1423a --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/components/UserProfile/AddAuthenticatorApp.tsx @@ -0,0 +1,124 @@ +import type { TOTPResource } from '@clerk/types'; +import React from 'react'; + +import { QRCode } from '../../common'; +import { useCoreUser } from '../../contexts'; +import type { LocalizationKey } from '../../customizables'; +import { Button, Col, descriptors, localizationKeys, Text } from '../../customizables'; +import { + ClipboardInput, + ContentPage, + FormButtonContainer, + FullHeightLoader, + NavigateToFlowStartButton, + useCardState, +} from '../../elements'; +import { handleError } from '../../utils'; +import { UserProfileBreadcrumbs } from './UserProfileNavbar'; + +type AddAuthenticatorAppProps = { + title: LocalizationKey; + onContinue: () => void; +}; + +type DisplayFormat = 'qr' | 'uri'; + +export const AddAuthenticatorApp = (props: AddAuthenticatorAppProps) => { + const { title, onContinue } = props; + const user = useCoreUser(); + const card = useCardState(); + const [totp, setTOTP] = React.useState(undefined); + const [displayFormat, setDisplayFormat] = React.useState('qr'); + + // TODO: React18 + // Non-idempotent useEffect + React.useEffect(() => { + void user + .createTOTP() + .then((totp: TOTPResource) => setTOTP(totp)) + .catch(err => handleError(err, [], card.setError)); + }, []); + + if (card.error) { + return ; + } + + return ( + + {!totp && } + + {totp && ( + <> + + {displayFormat == 'qr' && ( + <> + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/Alert.tsx b/packages/clerk-js/src/ui-retheme/elements/Alert.tsx new file mode 100644 index 0000000000..80e46b02c3 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/Alert.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { Alert as AlertCust, AlertIcon, Col, descriptors, Text } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; +import { animations } from '../styledSystem'; + +type _AlertProps = { + variant?: 'danger' | 'warning'; + title?: LocalizationKey | string; + subtitle?: LocalizationKey | string; +}; + +type AlertProps = Omit, keyof _AlertProps> & _AlertProps; + +export const Alert = (props: AlertProps): JSX.Element | null => { + const { children, title, subtitle, variant = 'warning', ...rest } = props; + + if (!children && !title && !subtitle) { + return null; + } + + return ( + + + + + {children} + + {subtitle && ( + + )} + + + ); +}; + +export const CardAlert = React.memo((props: AlertProps) => { + return ( + ({ + willChange: 'transform, opacity, height', + animation: `${animations.textInBig} ${theme.transitionDuration.$slow}`, + })} + {...props} + /> + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/ApplicationLogo.tsx b/packages/clerk-js/src/ui-retheme/elements/ApplicationLogo.tsx new file mode 100644 index 0000000000..5106e3b468 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/ApplicationLogo.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { useEnvironment } from '../contexts'; +import { descriptors, Flex, Image, useAppearance } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; +import { RouterLink } from './RouterLink'; + +type WidthInRem = `${string}rem`; +const getContainerHeightForImageRatio = (imageRef: React.RefObject, remWidth: WidthInRem) => { + const baseFontSize = 16; + const base = Number.parseFloat(remWidth.replace('rem', '')) * baseFontSize; + if (!imageRef.current) { + return base; + } + const ratio = imageRef.current.naturalWidth / imageRef.current.naturalHeight; + let newHeight = `${base}px`; + if (ratio <= 1) { + // logo is taller than it is wide + newHeight = `${2 * base}px`; + } else if (ratio > 1 && ratio <= 2) { + // logo is up to 2x wider than it is tall + newHeight = `${(2 * base) / ratio}px`; + } + return newHeight; +}; + +type ApplicationLogoProps = PropsOfComponent; + +export const ApplicationLogo = (props: ApplicationLogoProps) => { + const imageRef = React.useRef(null); + const [loaded, setLoaded] = React.useState(false); + const { logoImageUrl, applicationName, homeUrl } = useEnvironment().displayConfig; + const { parsedLayout } = useAppearance(); + const imageSrc = parsedLayout.logoImageUrl || logoImageUrl; + const logoUrl = parsedLayout.logoLinkUrl || homeUrl; + + if (!imageSrc) { + return null; + } + + const image = ( + {applicationName} setLoaded(true)} + sx={{ + display: loaded ? 'inline-block' : 'none', + height: '100%', + }} + /> + ); + + return ( + ({ + height: getContainerHeightForImageRatio(imageRef, theme.sizes.$6), + objectFit: 'cover', + }), + props.sx, + ]} + > + {logoUrl ? {image} : image} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/ArrowBlockButton.tsx b/packages/clerk-js/src/ui-retheme/elements/ArrowBlockButton.tsx new file mode 100644 index 0000000000..6f98aeed51 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/ArrowBlockButton.tsx @@ -0,0 +1,144 @@ +import React, { isValidElement } from 'react'; + +import type { Button, LocalizationKey } from '../customizables'; +import { Flex, Icon, SimpleButton, Spinner, Text } from '../customizables'; +import type { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; +import { ArrowRightIcon } from '../icons'; +import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; + +type ArrowBlockButtonProps = PropsOfComponent & { + rightIcon?: React.ComponentType; + rightIconSx?: ThemableCssProp; + leftIcon?: React.ComponentType | React.ReactElement; + leftIconSx?: ThemableCssProp; + leftIconElementDescriptor?: ElementDescriptor; + leftIconElementId?: ElementId; + badge?: React.ReactElement; + textElementDescriptor?: ElementDescriptor; + textElementId?: ElementId; + arrowElementDescriptor?: ElementDescriptor; + arrowElementId?: ElementId; + spinnerElementDescriptor?: ElementDescriptor; + spinnerElementId?: ElementId; + textLocalizationKey?: LocalizationKey; +}; + +export const ArrowBlockButton = (props: ArrowBlockButtonProps) => { + const { + rightIcon = ArrowRightIcon, + rightIconSx, + leftIcon, + leftIconSx, + leftIconElementId, + leftIconElementDescriptor, + isLoading, + children, + textElementDescriptor, + textElementId, + spinnerElementDescriptor, + spinnerElementId, + arrowElementDescriptor, + arrowElementId, + textLocalizationKey, + badge, + ...rest + } = props; + + const isIconElement = isValidElement(leftIcon); + + return ( + [ + { + gap: theme.space.$4, + position: 'relative', + justifyContent: 'flex-start', + borderColor: theme.colors.$blackAlpha200, + '--arrow-opacity': '0', + '--arrow-transform': `translateX(-${theme.space.$2});`, + '&:hover,&:focus ': { + '--arrow-opacity': '0.5', + '--arrow-transform': 'translateX(0px);', + }, + }, + props.sx, + ]} + > + {(isLoading || leftIcon) && ( + ({ flex: `0 0 ${theme.space.$5}` })} + > + {isLoading ? ( + + ) : !isIconElement && leftIcon ? ( + ({ + color: theme.colors.$blackAlpha600, + width: theme.sizes.$5, + position: 'absolute', + }), + leftIconSx, + ]} + /> + ) : ( + leftIcon + )} + + )} + + + {children} + + {badge} + + ({ + transition: 'all 100ms ease', + minWidth: theme.sizes.$4, + minHeight: theme.sizes.$4, + width: '1em', + height: '1em', + opacity: `var(--arrow-opacity)`, + transform: `var(--arrow-transform)`, + }), + rightIconSx, + ]} + /> + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/Avatar.tsx b/packages/clerk-js/src/ui-retheme/elements/Avatar.tsx new file mode 100644 index 0000000000..9a30961169 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/Avatar.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +import { Box, descriptors, Flex, Image, Text } from '../customizables'; +import type { ElementDescriptor } from '../customizables/elementDescriptors'; +import type { InternalTheme } from '../foundations'; +import type { PropsOfComponent } from '../styledSystem'; +import { common } from '../styledSystem'; + +type AvatarProps = PropsOfComponent & { + size?: (theme: InternalTheme) => string | number; + title?: string; + initials?: string; + imageUrl?: string | null; + imageFetchSize?: number; + rounded?: boolean; + boxElementDescriptor?: ElementDescriptor; + imageElementDescriptor?: ElementDescriptor; +}; + +export const Avatar = (props: AvatarProps) => { + const { + size = () => 26, + title, + initials, + imageUrl, + rounded = true, + imageFetchSize = 80, + sx, + boxElementDescriptor, + imageElementDescriptor, + } = props; + const [error, setError] = React.useState(false); + + const ImgOrFallback = + initials && (!imageUrl || error) ? ( + + ) : ( + {title} setError(true)} + size={imageFetchSize} + /> + ); + + // TODO: Revise size handling. Do we need to be this dynamic or should we use the theme instead? + return ( + ({ + flexShrink: 0, + borderRadius: rounded ? t.radii.$circle : t.radii.$md, + overflow: 'hidden', + width: size(t), + height: size(t), + backgroundColor: t.colors.$avatarBackground, + backgroundClip: 'padding-box', + position: 'relative', + boxShadow: 'var(--cl-shimmer-hover-shadow)', + transition: `box-shadow ${t.transitionDuration.$slower} ${t.transitionTiming.$easeOut}`, + }), + sx, + ]} + > + {ImgOrFallback} + + {/* /** + * This Box is the "shimmer" effect for the avatar. + * The ":after" selector is responsible for the border shimmer animation. + */} + ({ + overflow: 'hidden', + background: t.colors.$colorShimmer, + position: 'absolute', + width: '25%', + height: '100%', + transition: `all ${t.transitionDuration.$slower} ${t.transitionTiming.$easeOut}`, + transform: 'var(--cl-shimmer-hover-transform, skewX(-45deg) translateX(-300%))', + ':after': { + display: 'block', + boxSizing: 'border-box', + content: "''", + position: 'absolute', + width: '400%', + height: '100%', + transform: 'var(--cl-shimmer-hover-after-transform, skewX(45deg) translateX(75%))', + transition: `all ${t.transitionDuration.$slower} ${t.transitionTiming.$easeOut}`, + border: t.borders.$heavy, + borderColor: t.colors.$colorShimmer, + borderRadius: rounded ? t.radii.$circle : t.radii.$md, + }, + })} + /> + + ); +}; + +const InitialsAvatarFallback = (props: { initials: string }) => { + const initials = props.initials; + + return ( + + {initials} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/AvatarUploader.tsx b/packages/clerk-js/src/ui-retheme/elements/AvatarUploader.tsx new file mode 100644 index 0000000000..38b488afc8 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/AvatarUploader.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { Button, Col, descriptors, Flex, localizationKeys, Text } from '../customizables'; +import { handleError } from '../utils'; +import { useCardState } from './contexts'; +import { FileDropArea } from './FileDropArea'; + +export type AvatarUploaderProps = { + title: LocalizationKey; + avatarPreview: React.ReactElement; + onAvatarChange: (file: File) => Promise; + onAvatarRemove?: (() => void) | null; + avatarPreviewPlaceholder?: React.ReactElement | null; +}; + +export const fileToBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = error => reject(error); + }); +}; + +export const AvatarUploader = (props: AvatarUploaderProps) => { + const [showUpload, setShowUpload] = React.useState(false); + const [objectUrl, setObjectUrl] = React.useState(); + const card = useCardState(); + + const { onAvatarChange, onAvatarRemove, title, avatarPreview, avatarPreviewPlaceholder, ...rest } = props; + + const toggle = () => { + setShowUpload(!showUpload); + }; + + const handleFileDrop = (file: File | null) => { + if (file === null) { + return setObjectUrl(''); + } + + void fileToBase64(file).then(setObjectUrl); + card.setLoading(); + return onAvatarChange(file) + .then(() => { + toggle(); + card.setIdle(); + }) + .catch(err => handleError(err, [], card.setError)); + }; + + const handleRemove = () => { + card.setLoading(); + handleFileDrop(null); + return onAvatarRemove?.(); + }; + + const previewElement = objectUrl + ? React.cloneElement(avatarPreview, { imageUrl: objectUrl }) + : avatarPreviewPlaceholder + ? React.cloneElement(avatarPreviewPlaceholder, { onClick: toggle }) + : avatarPreview; + + return ( + + + {previewElement} + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/CodeControl.tsx b/packages/clerk-js/src/ui-retheme/elements/CodeControl.tsx new file mode 100644 index 0000000000..8f82ebb7cb --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/CodeControl.tsx @@ -0,0 +1,253 @@ +import React from 'react'; + +import { descriptors, Flex, Input, Spinner } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; +import { common } from '../styledSystem'; +import type { FormControlState } from '../utils'; +import { FormFeedback } from './FormControl'; + +type UseCodeInputOptions = { + length?: number; +}; + +type onCodeEntryFinishedCallback = (code: string) => unknown; +type onCodeEntryFinished = (cb: onCodeEntryFinishedCallback) => void; + +type UseCodeControlReturn = ReturnType; + +export const useCodeControl = (formControl: FormControlState, options?: UseCodeInputOptions) => { + const otpControlRef = React.useRef(); + const userOnCodeEnteredCallback = React.useRef(); + const defaultValue = formControl.value; + const { feedback, feedbackType, onChange } = formControl; + const { length = 6 } = options || {}; + const [values, setValues] = React.useState(() => + defaultValue ? defaultValue.split('').slice(0, length) : Array.from({ length }, () => ''), + ); + + const onCodeEntryFinished: onCodeEntryFinished = cb => { + userOnCodeEnteredCallback.current = cb; + }; + + React.useEffect(() => { + const len = values.filter(c => c).length; + if (len === length) { + const code = values.map(c => c || ' ').join(''); + userOnCodeEnteredCallback.current?.(code); + } else { + const code = values.join(''); + onChange?.({ target: { value: code } } as any); + } + }, [values.toString()]); + + const otpInputProps = { length, values, setValues, feedback, feedbackType, ref: otpControlRef }; + return { otpInputProps, onCodeEntryFinished, reset: () => otpControlRef.current?.reset() }; +}; + +type CodeControlProps = UseCodeControlReturn['otpInputProps'] & { + isDisabled?: boolean; + errorText?: string; + isSuccessfullyFilled?: boolean; + isLoading?: boolean; +}; + +export const CodeControl = React.forwardRef<{ reset: any }, CodeControlProps>((props, ref) => { + const [disabled, setDisabled] = React.useState(false); + const refs = React.useRef>([]); + const firstClickRef = React.useRef(false); + const { values, setValues, isDisabled, feedback, feedbackType, isSuccessfullyFilled, isLoading, length } = props; + + React.useImperativeHandle(ref, () => ({ + reset: () => { + setValues(values.map(() => '')); + setDisabled(false); + setTimeout(() => focusInputAt(0), 0); + }, + })); + + React.useLayoutEffect(() => { + setTimeout(() => focusInputAt(0), 0); + }, []); + + React.useEffect(() => { + if (feedback) { + setDisabled(true); + } + }, [feedback]); + + const handleMultipleCharValue = ({ eventValue, inputPosition }: { eventValue: string; inputPosition: number }) => { + const eventValues = (eventValue || '').split(''); + + if (eventValues.length === 0 || !eventValues.every(c => isValidInput(c))) { + return; + } + + if (eventValues.length === length) { + setValues([...eventValues]); + focusInputAt(length - 1); + return; + } + + const mergedValues = values.map((value, i) => + i < inputPosition ? value : eventValues[i - inputPosition] || value, + ); + setValues(mergedValues); + focusInputAt(inputPosition + eventValues.length); + }; + + const changeValueAt = (index: number, newValue: string) => { + const newValues = [...values]; + newValues[index] = newValue; + setValues(newValues); + }; + + const focusInputAt = (index: number) => { + const clampedIndex = Math.min(Math.max(0, index), refs.current.length - 1); + const ref = refs.current[clampedIndex]; + if (ref) { + ref.focus(); + values[clampedIndex] && ref.select(); + } + }; + + const handleOnClick = (index: number) => (e: React.MouseEvent) => { + e.preventDefault(); + // Focus on the first digit, when the first click happens. + // This is helpful especially for mobile (iOS) devices that cannot autofocus + // and user needs to manually tap the input area + if (!firstClickRef.current) { + focusInputAt(0); + firstClickRef.current = true; + return; + } + focusInputAt(index); + }; + + const handleOnChange = (index: number) => (e: React.ChangeEvent) => { + e.preventDefault(); + handleMultipleCharValue({ eventValue: e.target.value || '', inputPosition: index }); + }; + + const handleOnInput = (index: number) => (e: React.FormEvent) => { + e.preventDefault(); + if (isValidInput((e.target as any).value)) { + // If a user types on an input that already has a value and the new + // value is the same as the old one, onChange will not fire so we + // manually move focus to the next input + focusInputAt(index + 1); + } + }; + + const handleOnPaste = (index: number) => (e: React.ClipboardEvent) => { + e.preventDefault(); + handleMultipleCharValue({ eventValue: e.clipboardData.getData('text/plain') || '', inputPosition: index }); + }; + + const handleOnKeyDown = (index: number) => (e: React.KeyboardEvent) => { + switch (e.key) { + case 'Backspace': + e.preventDefault(); + changeValueAt(index, ''); + focusInputAt(index - 1); + return; + case 'ArrowLeft': + e.preventDefault(); + focusInputAt(index - 1); + return; + case 'ArrowRight': + e.preventDefault(); + focusInputAt(index + 1); + return; + case ' ': + e.preventDefault(); + return; + } + }; + + return ( + + + {values.map((value, index: number) => ( + (refs.current[index] = node)} + autoFocus={index === 0 || undefined} + autoComplete='one-time-code' + aria-label={`${index === 0 ? 'Enter verification code. ' : ''} Digit ${index + 1}`} + isDisabled={isDisabled || isLoading || disabled || isSuccessfullyFilled} + hasError={feedbackType === 'error'} + isSuccessfullyFilled={isSuccessfullyFilled} + type='text' + inputMode='numeric' + name={`codeInput-${index}`} + /> + ))} + {isLoading && ( + ({ marginLeft: theme.space.$2 })} + /> + )} + + + + ); +}); + +const SingleCharInput = React.forwardRef< + HTMLInputElement, + PropsOfComponent & { isSuccessfullyFilled?: boolean } +>((props, ref) => { + const { isSuccessfullyFilled, ...rest } = props; + return ( + ({ + textAlign: 'center', + ...common.textVariants(theme).xlargeMedium, + padding: `${theme.space.$0x5} 0`, + boxSizing: 'content-box', + minWidth: '1ch', + maxWidth: theme.sizes.$7, + borderRadius: theme.radii.$none, + border: 'none', + borderBottom: theme.borders.$heavy, + ...(isSuccessfullyFilled ? { borderColor: theme.colors.$success500 } : common.borderColor(theme, props)), + backgroundColor: 'unset', + '&:focus': { + boxShadow: 'none', + borderColor: theme.colors.$primary500, + }, + })} + {...rest} + /> + ); +}); + +const isValidInput = (char: string) => char != undefined && Number.isInteger(+char); diff --git a/packages/clerk-js/src/ui-retheme/elements/CodeForm.tsx b/packages/clerk-js/src/ui-retheme/elements/CodeForm.tsx new file mode 100644 index 0000000000..597a5045a1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/CodeForm.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { Col, descriptors, Text } from '../customizables'; +import type { useCodeControl } from './CodeControl'; +import { CodeControl } from './CodeControl'; +import { TimerButton } from './TimerButton'; + +type CodeFormProps = { + title: LocalizationKey; + subtitle: LocalizationKey; + resendButton?: LocalizationKey; + isLoading: boolean; + success: boolean; + onResendCodeClicked?: React.MouseEventHandler; + codeControl: ReturnType; +}; + +export const CodeForm = (props: CodeFormProps) => { + const { subtitle, title, isLoading, success, codeControl, onResendCodeClicked, resendButton } = props; + + return ( + + + + + {onResendCodeClicked && ( + ({ marginTop: theme.space.$6 })} + localizationKey={resendButton} + /> + )} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/ContentPage.tsx b/packages/clerk-js/src/ui-retheme/elements/ContentPage.tsx new file mode 100644 index 0000000000..95d333e32f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/ContentPage.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { Col, descriptors } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; +import { CardAlert, Header, NavbarMenuButtonRow, useCardState } from './index'; + +type PageProps = PropsOfComponent & { + headerTitle: LocalizationKey | string; + headerTitleTextVariant?: PropsOfComponent['textVariant']; + breadcrumbTitle?: LocalizationKey; + Breadcrumbs?: React.ComponentType | null; + headerSubtitle?: LocalizationKey; + headerSubtitleTextVariant?: PropsOfComponent['variant']; +}; + +export const ContentPage = (props: PageProps) => { + const { + headerTitle, + headerTitleTextVariant, + headerSubtitle, + headerSubtitleTextVariant, + breadcrumbTitle, + children, + Breadcrumbs, + sx, + ...rest + } = props; + const card = useCardState(); + + return ( + ({ minHeight: t.sizes.$120 }), sx]} + > + + {card.error} + + {Breadcrumbs && ( + ({ marginBottom: t.space.$5 })} + /> + )} + + {headerSubtitle && ( + + )} + + {children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/Divider.tsx b/packages/clerk-js/src/ui-retheme/elements/Divider.tsx new file mode 100644 index 0000000000..ce5c2e8bd3 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/Divider.tsx @@ -0,0 +1,35 @@ +import { descriptors, Flex, localizationKeys, Text } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; + +export const Divider = (props: Omit, 'elementDescriptor'>) => { + return ( + + + ({ margin: `0 ${t.space.$4}` })} + /> + + + ); +}; + +const DividerLine = () => { + return ( + ({ + flex: '1', + height: '1px', + backgroundColor: t.colors.$blackAlpha300, + })} + /> + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/ErrorCard.tsx b/packages/clerk-js/src/ui-retheme/elements/ErrorCard.tsx new file mode 100644 index 0000000000..4edd2eda94 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/ErrorCard.tsx @@ -0,0 +1,76 @@ +import React from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { descriptors, Flex, Flow, Icon, localizationKeys, Text } from '../customizables'; +import { useSupportEmail } from '../hooks/useSupportEmail'; +import { Email } from '../icons'; +import { CardAlert } from './Alert'; +import { ArrowBlockButton } from './ArrowBlockButton'; +import { Card } from './Card'; +import { useCardState } from './contexts'; +import { Footer } from './Footer'; +import { Header } from './Header'; + +type ErrorCardProps = { + cardTitle?: LocalizationKey; + cardSubtitle?: LocalizationKey; + message?: LocalizationKey; + onBackLinkClick?: React.MouseEventHandler | undefined; +}; + +export const ErrorCard = (props: ErrorCardProps) => { + const { onBackLinkClick } = props; + const card = useCardState(); + const supportEmail = useSupportEmail(); + + const handleEmailSupport = () => { + window.location.href = `mailto:${supportEmail}`; + }; + + return ( + + + {card.error} + + {onBackLinkClick && } + + {props.cardSubtitle && } + + {/*TODO: extract main in its own component */} + + {props.message && ( + + )} + {/*TODO: extract */} + + ({ color: theme.colors.$blackAlpha500 })} + /> + } + /> + + + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/FieldControl.tsx b/packages/clerk-js/src/ui-retheme/elements/FieldControl.tsx new file mode 100644 index 0000000000..cebfec3819 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/FieldControl.tsx @@ -0,0 +1,227 @@ +import type { FieldId } from '@clerk/types'; +import type { PropsWithChildren } from 'react'; +import React, { forwardRef } from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { + descriptors, + Flex, + FormControl as FormControlPrim, + FormLabel, + Icon as IconCustomizable, + Input, + Link, + localizationKeys, + Text, + useLocalizations, +} from '../customizables'; +import { FormFieldContextProvider, sanitizeInputProps, useFormField } from '../primitives/hooks'; +import type { PropsOfComponent } from '../styledSystem'; +import type { useFormControl as useFormControlUtil } from '../utils'; +import { useFormControlFeedback } from '../utils'; +import { useCardState } from './contexts'; +import type { FormFeedbackProps } from './FormControl'; +import { FormFeedback } from './FormControl'; +import { RadioItem } from './RadioGroup'; + +type FormControlProps = Omit, 'label' | 'placeholder' | 'disabled' | 'required'> & + ReturnType>['props']; + +const Root = (props: PropsWithChildren) => { + const card = useCardState(); + const { + id, + isRequired, + sx, + setError, + setInfo, + setSuccess, + setWarning, + setHasPassedComplexity, + clearFeedback, + feedbackType, + feedback, + isFocused, + } = props; + + const { debounced: debouncedState } = useFormControlFeedback({ feedback, feedbackType, isFocused }); + + const isDisabled = props.isDisabled || card.isLoading; + + return ( + + {/*Most of our primitives still depend on this provider.*/} + {/*TODO: In follow-up PRs these will be removed*/} + + {props.children} + + + ); +}; + +const FieldAction = ( + props: PropsWithChildren<{ localizationKey?: LocalizationKey | string; onClick?: React.MouseEventHandler }>, +) => { + const { fieldId, isDisabled } = useFormField(); + + if (!props.localizationKey && !props.children) { + return null; + } + + return ( + { + e.preventDefault(); + props.onClick?.(e); + }} + > + {props.children} + + ); +}; + +const FieldOptionalLabel = () => { + const { fieldId, isDisabled } = useFormField(); + return ( + + ); +}; + +const FieldLabelIcon = (props: { icon?: React.ComponentType }) => { + const { t } = useLocalizations(); + if (!props.icon) { + return null; + } + + return ( + + ({ + marginLeft: theme.space.$0x5, + color: theme.colors.$blackAlpha400, + width: theme.sizes.$4, + height: theme.sizes.$4, + })} + /> + + ); +}; + +const FieldLabel = (props: PropsWithChildren<{ localizationKey?: LocalizationKey | string }>) => { + const { isRequired, id, label, isDisabled, hasError } = useFormField(); + + if (!(props.localizationKey || label) && !props.children) { + return null; + } + + return ( + + {props.children} + + ); +}; + +const FieldLabelRow = (props: PropsWithChildren) => { + const { fieldId } = useFormField(); + return ( + ({ + marginBottom: theme.space.$1, + marginLeft: 0, + })} + > + {props.children} + + ); +}; + +const FieldFeedback = (props: Pick) => { + const { feedback, feedbackType, isFocused, fieldId } = useFormField(); + const { debounced } = useFormControlFeedback({ feedback, feedbackType, isFocused }); + + return ( + + ); +}; + +const InputElement = forwardRef((_, ref) => { + const { t } = useLocalizations(); + const formField = useFormField(); + const { placeholder, ...inputProps } = sanitizeInputProps(formField); + return ( + + ); +}); + +export const Field = { + Root: Root, + Label: FieldLabel, + LabelRow: FieldLabelRow, + Input: InputElement, + RadioItem: RadioItem, + Action: FieldAction, + AsOptional: FieldOptionalLabel, + LabelIcon: FieldLabelIcon, + Feedback: FieldFeedback, +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/FileDropArea.tsx b/packages/clerk-js/src/ui-retheme/elements/FileDropArea.tsx new file mode 100644 index 0000000000..af568fa11a --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/FileDropArea.tsx @@ -0,0 +1,118 @@ +import React from 'react'; + +import { Button, Col, descriptors, localizationKeys, Text } from '../customizables'; +import { Folder } from '../icons'; +import { animations, mqu } from '../styledSystem'; +import { colors } from '../utils'; +import { IconCircle } from './IconCircle'; + +const MAX_SIZE_BYTES = 10 * 1000 * 1000; +const SUPPORTED_MIME_TYPES = Object.freeze(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); + +const validType = (f: File | DataTransferItem) => SUPPORTED_MIME_TYPES.includes(f.type); +const validSize = (f: File) => f.size <= MAX_SIZE_BYTES; +const validFile = (f: File) => validType(f) && validSize(f); + +type FileDropAreaProps = { + onFileDrop: (file: File) => any; +}; + +export const FileDropArea = (props: FileDropAreaProps) => { + const { onFileDrop } = props; + const [status, setStatus] = React.useState<'idle' | 'valid' | 'invalid' | 'loading'>('idle'); + const inputRef = React.useRef(null); + const openDialog = () => inputRef.current?.click(); + + const onDragEnter = (ev: React.DragEvent) => { + const item = ev.dataTransfer.items[0]; + setStatus(item && !validType(item) ? 'invalid' : 'valid'); + }; + + const onDragLeave = (ev: React.DragEvent) => { + if (!ev.currentTarget.contains(ev.relatedTarget as Element)) { + setStatus('idle'); + } + }; + + const onDragOver = (ev: React.DragEvent) => { + ev.preventDefault(); + }; + + const onDrop = (ev: React.DragEvent) => { + ev.preventDefault(); + onDragLeave(ev); + upload(ev.dataTransfer.files[0]); + }; + + const upload = (f: File | undefined) => { + if (f && validFile(f)) { + setStatus('loading'); + onFileDrop(f); + } + }; + + const events = { onDragEnter, onDragLeave, onDragOver, onDrop }; + + return ( + + upload(e.currentTarget.files?.[0])} + /> + ({ + height: t.space.$60, + [mqu.sm]: { height: '10 rem' }, + backgroundColor: { + idle: t.colors.$blackAlpha50, + loading: t.colors.$blackAlpha50, + valid: colors.setAlpha(t.colors.$success500, 0.2), + invalid: colors.setAlpha(t.colors.$danger500, 0.2), + }[status], + borderRadius: t.radii.$xl, + animation: `${animations.expandIn(t.space.$60)} ${t.transitionDuration.$fast} ease`, + })} + > + + {status === 'loading' ? ( + Uploading... + ) : ( + <> + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/IconCircle.tsx b/packages/clerk-js/src/ui-retheme/elements/IconCircle.tsx new file mode 100644 index 0000000000..2a4ad8a91a --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/IconCircle.tsx @@ -0,0 +1,37 @@ +import { Flex, Icon } from '../customizables'; +import type { ElementDescriptor } from '../customizables/elementDescriptors'; +import type { PropsOfComponent } from '../styledSystem'; + +type IconCircleProps = Pick, 'icon'> & + PropsOfComponent & { + boxElementDescriptor?: ElementDescriptor; + iconElementDescriptor?: ElementDescriptor; + }; + +export const IconCircle = (props: IconCircleProps) => { + const { icon, boxElementDescriptor, iconElementDescriptor, sx, ...rest } = props; + + return ( + ({ + backgroundColor: t.colors.$blackAlpha50, + width: t.sizes.$16, + height: t.sizes.$16, + borderRadius: t.radii.$circle, + }), + sx, + ]} + {...rest} + > + ({ color: theme.colors.$blackAlpha600 })} + /> + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/IdentityPreview.tsx b/packages/clerk-js/src/ui-retheme/elements/IdentityPreview.tsx new file mode 100644 index 0000000000..75c7e9e667 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/IdentityPreview.tsx @@ -0,0 +1,128 @@ +import React from 'react'; + +import { Button, descriptors, Flex, Icon, Text } from '../customizables'; +import { UserAvatar } from '../elements'; +import { AuthApp, PencilEdit } from '../icons'; +import type { PropsOfComponent } from '../styledSystem'; +import { formatSafeIdentifier, getFlagEmojiFromCountryIso, isMaskedIdentifier, parsePhoneString } from '../utils'; + +type IdentityPreviewProps = { + avatarUrl: string | null | undefined; + identifier: string | null | undefined; + onClick?: React.MouseEventHandler; +} & PropsOfComponent; + +export const IdentityPreview = (props: IdentityPreviewProps) => { + const { avatarUrl = 'https://img.clerk.com/static/avatar_placeholder.jpeg', identifier, onClick, ...rest } = props; + const refs = React.useRef({ avatarUrl, identifier: formatSafeIdentifier(identifier) }); + + const edit = onClick && ( + + ); + + if (!refs.current.identifier) { + return ( + + + {edit} + + ); + } + + if (isMaskedIdentifier(refs.current.identifier) || !refs.current.identifier.startsWith('+')) { + return ( + + + {edit} + + ); + } + + const parsedPhone = parsePhoneString(refs.current.identifier || ''); + const flag = getFlagEmojiFromCountryIso(parsedPhone.iso); + return ( + + + {edit} + + ); +}; + +const IdentifierContainer = (props: React.PropsWithChildren) => { + return ( + + ); +}; + +const UsernameOrEmailIdentifier = (props: any) => { + return ( + <> + t.sizes.$5} + /> + {props.identifier} + + ); +}; + +const PhoneIdentifier = (props: { identifier: string; flag?: string }) => { + return ( + <> + ({ fontSize: t.fontSizes.$sm })}>{props.flag} + {props.identifier} + + ); +}; + +const Authenticator = () => { + return ( + <> + ({ color: t.colors.$blackAlpha700 })} + /> + Authenticator app + + ); +}; + +const Container = (props: React.PropsWithChildren) => { + return ( + ({ + minHeight: t.space.$9x5, + maxWidth: 'fit-content', + backgroundColor: t.colors.$blackAlpha20, + padding: `${t.space.$1x5} ${t.space.$4}`, + borderRadius: t.radii.$2xl, + border: `${t.borders.$normal} ${t.colors.$blackAlpha200}`, + })} + {...props} + /> + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/InformationBox.tsx b/packages/clerk-js/src/ui-retheme/elements/InformationBox.tsx new file mode 100644 index 0000000000..6d3d2fe02c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/InformationBox.tsx @@ -0,0 +1,29 @@ +import type { LocalizationKey } from '../customizables'; +import { Flex, Icon, Text } from '../customizables'; +import { InformationCircle } from '../icons'; + +type InformationBoxProps = { + message: LocalizationKey | string; +}; + +export function InformationBox(props: InformationBoxProps) { + return ( + ({ + gap: t.space.$2, + padding: `${t.space.$3} ${t.space.$4}`, + backgroundColor: t.colors.$blackAlpha50, + borderRadius: t.radii.$md, + })} + > + ({ opacity: t.opacity.$disabled })} + /> + ({ color: t.colors.$blackAlpha700 })} + /> + + ); +} diff --git a/packages/clerk-js/src/ui-retheme/elements/InputGroup.tsx b/packages/clerk-js/src/ui-retheme/elements/InputGroup.tsx new file mode 100644 index 0000000000..50950b67e7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/InputGroup.tsx @@ -0,0 +1,74 @@ +import { forwardRef } from 'react'; + +import { descriptors, Flex, Input, Text } from '../customizables'; +import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; + +type InputGroupProps = PropsOfComponent; + +export const InputGroup = forwardRef< + HTMLInputElement, + InputGroupProps & { + groupPreffix?: string; + groupSuffix?: string; + } +>((props, ref) => { + const { sx, groupPreffix, groupSuffix, ...rest } = props; + + const inputBorder = groupPreffix + ? { + borderTopLeftRadius: '0', + borderBottomLeftRadius: '0', + } + : { + borderTopRightRadius: '0', + borderBottomRightRadius: '0', + }; + + const textProps: ThemableCssProp = t => ({ + paddingInline: t.space.$2, + backgroundColor: t.colors.$blackAlpha50, + borderTopRightRadius: '0', + borderBottomRightRadius: '0', + width: 'fit-content', + display: 'flex', + alignItems: 'center', + }); + + return ( + ({ + position: 'relative', + borderRadius: theme.radii.$md, + zIndex: 1, + border: theme.borders.$normal, + borderColor: theme.colors.$blackAlpha300, // we use this value in the Input primitive + })} + > + {groupPreffix && {groupPreffix}} + + {groupSuffix && ( + + {groupSuffix} + + )} + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/InputWithIcon.tsx b/packages/clerk-js/src/ui-retheme/elements/InputWithIcon.tsx new file mode 100644 index 0000000000..a8f7dea875 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/InputWithIcon.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { Flex, Input } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; + +type InputWithIcon = PropsOfComponent & { leftIcon?: React.ReactElement }; + +export const InputWithIcon = React.forwardRef((props, ref) => { + const { leftIcon, sx, ...rest } = props; + return ( + ({ + width: '100%', + position: 'relative', + '& .cl-internal-icon': { + position: 'absolute', + left: theme.space.$4, + width: theme.sizes.$3x5, + height: theme.sizes.$3x5, + }, + })} + > + {leftIcon && React.cloneElement(leftIcon, { className: 'cl-internal-icon' })} + ({ + width: '100%', + paddingLeft: theme.space.$10, + }), + sx, + ]} + ref={ref} + /> + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/InvisibleRootBox.tsx b/packages/clerk-js/src/ui-retheme/elements/InvisibleRootBox.tsx new file mode 100644 index 0000000000..56a8d3e577 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/InvisibleRootBox.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { makeCustomizable } from '../customizables/makeCustomizable'; + +type RootBoxProps = React.PropsWithChildren<{ className: string }>; + +const _InvisibleRootBox = React.memo((props: RootBoxProps) => { + const [showSpan, setShowSpan] = React.useState(true); + const parentRef = React.useRef(null); + + React.useEffect(() => { + const parent = parentRef.current; + if (!parent) { + return; + } + if (showSpan) { + setShowSpan(false); + } + parent.className = props.className; + }, [props.className]); + + return ( + <> + {props.children} + {showSpan && ( + (parentRef.current = el ? el.parentElement : parentRef.current)} + aria-hidden + style={{ display: 'none' }} + /> + )} + + ); +}); + +export const InvisibleRootBox = makeCustomizable(_InvisibleRootBox, { + defaultStyles: t => ({ + boxSizing: 'border-box', + width: 'fit-content', + + fontFamily: t.fonts.$main, + fontStyle: t.fontStyles.$normal, + }), +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/LoadingCard.tsx b/packages/clerk-js/src/ui-retheme/elements/LoadingCard.tsx new file mode 100644 index 0000000000..b3f9af49f7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/LoadingCard.tsx @@ -0,0 +1,38 @@ +import type { PropsWithChildren } from 'react'; + +import { descriptors, Flex } from '../customizables'; +import { Spinner } from '../primitives'; +import { CardAlert } from './Alert'; +import { Card } from './Card'; +import { useCardState, withCardStateProvider } from './contexts'; + +export const LoadingCardContainer = ({ children }: PropsWithChildren) => { + return ( + ({ + marginTop: theme.space.$16, + marginBottom: theme.space.$14, + })} + > + + {children} + + ); +}; + +export const LoadingCard = withCardStateProvider(() => { + const card = useCardState(); + return ( + + {card.error} + + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/Menu.tsx b/packages/clerk-js/src/ui-retheme/elements/Menu.tsx new file mode 100644 index 0000000000..4d6f2f64bd --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/Menu.tsx @@ -0,0 +1,196 @@ +import { createContextAndHook } from '@clerk/shared/react'; +import type { MenuId } from '@clerk/types'; +import type { PropsWithChildren } from 'react'; +import React, { cloneElement, isValidElement, useLayoutEffect, useRef } from 'react'; + +import { Button, Col, descriptors } from '../customizables'; +import type { UsePopoverReturn } from '../hooks'; +import { usePopover } from '../hooks'; +import type { PropsOfComponent } from '../styledSystem'; +import { animations } from '../styledSystem'; +import { colors } from '../utils/colors'; +import { withFloatingTree } from './contexts'; +import { Popover } from './Popover'; + +type MenuState = { + popoverCtx: UsePopoverReturn; + elementId?: MenuId; +}; + +const [MenuStateCtx, useMenuState] = createContextAndHook('MenuState'); + +type MenuProps = PropsWithChildren> & { elementId?: MenuId }; + +export const Menu = withFloatingTree((props: MenuProps) => { + const { elementId } = props; + const popoverCtx = usePopover({ + placement: 'right-start', + offset: 8, + bubbles: false, + }); + + const value = React.useMemo(() => ({ value: { popoverCtx, elementId } }), [{ ...popoverCtx }, elementId]); + + return ( + + ); +}); + +type MenuTriggerProps = React.PropsWithChildren>; + +export const MenuTrigger = (props: MenuTriggerProps) => { + const { children } = props; + const { popoverCtx, elementId } = useMenuState(); + const { reference, toggle } = popoverCtx; + + if (!isValidElement(children)) { + return null; + } + + return cloneElement(children, { + // @ts-expect-error + ref: reference, + elementDescriptor: descriptors.menuButton, + elementId: descriptors.menuButton.setId(elementId), + onClick: (e: React.MouseEvent) => { + children.props?.onClick?.(e); + toggle(); + }, + }); +}; + +const findMenuItem = (el: Element, siblingType: 'prev' | 'next', options?: { countSelf?: boolean }) => { + let tagName = options?.countSelf ? el.tagName : ''; + let sibling: Element | null = el; + while (sibling && tagName.toUpperCase() !== 'BUTTON') { + sibling = sibling[siblingType === 'prev' ? 'previousElementSibling' : 'nextElementSibling']; + tagName = sibling?.tagName ?? ''; + } + return sibling; +}; + +type MenuListProps = PropsOfComponent; + +export const MenuList = (props: MenuListProps) => { + const { sx, ...rest } = props; + const { popoverCtx, elementId } = useMenuState(); + const { floating, styles, isOpen, context, nodeId } = popoverCtx; + const containerRef = useRef(null); + + useLayoutEffect(() => { + const current = containerRef.current; + floating(current); + }, [isOpen]); + + const onKeyDown = (e: React.KeyboardEvent) => { + const current = containerRef.current; + if (!current) { + return; + } + + if (current !== document.activeElement) { + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + return (findMenuItem(current.children[0], 'next', { countSelf: true }) as HTMLElement)?.focus(); + } + }; + + return ( + + ({ + backgroundColor: colors.makeSolid(theme.colors.$colorBackground), + border: theme.borders.$normal, + outline: 'none', + borderRadius: theme.radii.$lg, + borderColor: theme.colors.$blackAlpha200, + paddingTop: theme.space.$2, + paddingBottom: theme.space.$2, + overflow: 'hidden', + top: `calc(100% + ${theme.space.$2})`, + animation: `${animations.dropdownSlideInScaleAndFade} ${theme.transitionDuration.$slower} ${theme.transitionTiming.$slowBezier}`, + transformOrigin: 'top center', + boxShadow: theme.shadows.$boxShadow1, + zIndex: theme.zIndices.$dropdown, + }), + sx, + ]} + style={styles} + {...rest} + /> + + ); +}; + +type MenuItemProps = PropsOfComponent & { + destructive?: boolean; +}; + +export const MenuItem = (props: MenuItemProps) => { + const { sx, onClick, destructive, ...rest } = props; + const { popoverCtx, elementId } = useMenuState(); + const { toggle } = popoverCtx; + const buttonRef = useRef(null); + + const onKeyDown = (e: React.KeyboardEvent) => { + const current = buttonRef.current; + if (!current) { + return; + } + + const key = e.key; + if (key !== 'ArrowUp' && key !== 'ArrowDown') { + return; + } + + e.preventDefault(); + const sibling = findMenuItem(current, key === 'ArrowUp' ? 'prev' : 'next'); + (sibling as HTMLElement)?.focus(); + }; + + return ( + + ); +}; + +export const NavbarMenuButtonRow = (props: PropsOfComponent) => { + const { open } = useUnsafeNavbarContext(); + const { t } = useLocalizations(); + + const navbarContextExistsInTree = !!open; + if (!navbarContextExistsInTree) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/NavigateToFlowStartButton.tsx b/packages/clerk-js/src/ui-retheme/elements/NavigateToFlowStartButton.tsx new file mode 100644 index 0000000000..86015e141a --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/NavigateToFlowStartButton.tsx @@ -0,0 +1,17 @@ +import { Button } from '../customizables'; +import { useNavigateToFlowStart } from '../hooks'; +import type { PropsOfComponent } from '../styledSystem'; + +type NavigateToFlowStartButtonProps = PropsOfComponent; + +export const NavigateToFlowStartButton = (props: NavigateToFlowStartButtonProps) => { + const { navigateToFlowStart } = useNavigateToFlowStart(); + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/ProfileCardContent.tsx b/packages/clerk-js/src/ui-retheme/elements/ProfileCardContent.tsx new file mode 100644 index 0000000000..6034b28351 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/ProfileCardContent.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import { Col, descriptors } from '../customizables'; +import { useRouter } from '../router'; +import { common, mqu } from '../styledSystem'; + +type ProfileCardContentProps = React.PropsWithChildren<{ contentRef?: React.RefObject }>; + +export const ProfileCardContent = (props: ProfileCardContentProps) => { + const { contentRef, children } = props; + const router = useRouter(); + const scrollPosRef = React.useRef(0); + + React.useEffect(() => { + const handleScroll = (e: Event) => { + const target = e.target as HTMLDivElement; + if (target.scrollTop) { + scrollPosRef.current = target.scrollTop; + } + }; + contentRef?.current?.addEventListener('scroll', handleScroll); + return () => contentRef?.current?.removeEventListener('scroll', handleScroll); + }, []); + + React.useLayoutEffect(() => { + if (scrollPosRef.current && contentRef?.current) { + contentRef.current.scrollTop = scrollPosRef.current; + } + }, [router.currentPath]); + + return ( + + ({ + flex: `1`, + padding: `${theme.space.$9x5} ${theme.space.$8}`, + [mqu.xs]: { + padding: `${theme.space.$8} ${theme.space.$5}`, + }, + ...common.maxHeightScroller(theme), + })} + ref={contentRef} + > + {children} + + + ); +}; + +const ScrollerContainer = (props: React.PropsWithChildren>) => { + return ( + ({ position: 'relative', borderRadius: t.radii.$xl, width: '100%', overflow: 'hidden' })} + {...props} + /> + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/RadioGroup.tsx b/packages/clerk-js/src/ui-retheme/elements/RadioGroup.tsx new file mode 100644 index 0000000000..2d857c0ce1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/RadioGroup.tsx @@ -0,0 +1,179 @@ +import { forwardRef, useId } from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { Col, descriptors, Flex, FormLabel, Input, Text } from '../customizables'; +import { sanitizeInputProps, useFormField } from '../primitives/hooks'; +import type { PropsOfComponent } from '../styledSystem'; + +/** + * @deprecated + */ +export const RadioGroup = ( + props: PropsOfComponent & { + radioOptions?: { + value: string; + label: string | LocalizationKey; + description?: string | LocalizationKey; + }[]; + }, +) => { + const { radioOptions, ...rest } = props; + return ( + + {radioOptions?.map(r => ( + + ))} + + ); +}; + +/** + * @deprecated + */ +const RadioGroupItem = (props: { + inputProps: PropsOfComponent; + value: string; + label: string | LocalizationKey; + description?: string | LocalizationKey; +}) => { + const id = useId(); + return ( + + ({ + width: 'fit-content', + marginTop: t.space.$0x5, + }), + props.inputProps.sx, + ]} + type='radio' + value={props.value} + checked={props.value === props.inputProps.value} + /> + + ({ + padding: `${t.space.$none} ${t.space.$2}`, + display: 'flex', + flexDirection: 'column', + })} + > + + + {props.description && ( + + )} + + + ); +}; + +const RadioIndicator = forwardRef((props, ref) => { + const formField = useFormField(); + const { value, placeholder, ...inputProps } = sanitizeInputProps(formField); + + return ( + ({ + width: 'fit-content', + marginTop: t.space.$0x5, + })} + type='radio' + value={props.value} + checked={props.value === value} + /> + ); +}); + +export const RadioLabel = (props: { + label: string | LocalizationKey; + description?: string | LocalizationKey; + id?: string; +}) => { + return ( + ({ + padding: `${t.space.$none} ${t.space.$2}`, + display: 'flex', + flexDirection: 'column', + })} + > + + + {props.description && ( + + )} + + ); +}; + +export const RadioItem = forwardRef< + HTMLInputElement, + { + value: string; + label: string | LocalizationKey; + description?: string | LocalizationKey; + } +>((props, ref) => { + const randomId = useId(); + return ( + + + + + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/ReversibleContainer.tsx b/packages/clerk-js/src/ui-retheme/elements/ReversibleContainer.tsx new file mode 100644 index 0000000000..4908ed5257 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/ReversibleContainer.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { useAppearance } from '../customizables'; +import { Divider } from './Divider'; + +export const SocialButtonsReversibleContainerWithDivider = (props: React.PropsWithChildren) => { + const appearance = useAppearance(); + const childrenWithDivider = interleaveElementInArray(React.Children.toArray(props.children), i => ( + + )); + + return ( + + {childrenWithDivider} + + ); +}; + +export const ReversibleContainer = (props: React.PropsWithChildren<{ reverse?: boolean }>) => { + const { children, reverse } = props; + return <>{reverse ? React.Children.toArray(children).reverse() : children}; +}; + +const interleaveElementInArray = (arr: A, generator: (i: number) => any): A => { + return arr.reduce((acc, child, i) => { + return i === arr.length - 1 ? [...acc, child] : [...acc, child, generator(i)]; + }, [] as any); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/RootBox.tsx b/packages/clerk-js/src/ui-retheme/elements/RootBox.tsx new file mode 100644 index 0000000000..b076028e34 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/RootBox.tsx @@ -0,0 +1,17 @@ +import { Col } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; + +export const RootBox = (props: PropsOfComponent) => { + return ( + ({ + boxSizing: 'border-box', + width: 'fit-content', + color: t.colors.$colorText, + fontFamily: t.fonts.$main, + fontStyle: t.fontStyles.$normal, + })} + /> + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/RouterLink.tsx b/packages/clerk-js/src/ui-retheme/elements/RouterLink.tsx new file mode 100644 index 0000000000..8dab8dc251 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/RouterLink.tsx @@ -0,0 +1,30 @@ +import { Link } from '../customizables'; +import { useRouter } from '../router'; +import type { PropsOfComponent } from '../styledSystem'; + +type RouterLinkProps = PropsOfComponent & { + to?: string; +}; + +export const RouterLink = (props: RouterLinkProps) => { + const { to, onClick: onClickProp, ...rest } = props; + const router = useRouter(); + + const toUrl = router.resolve(to || router.indexPath); + + const onClick: React.MouseEventHandler = e => { + e.preventDefault(); + if (onClickProp && !to) { + return onClickProp(e); + } + return router.navigate(toUrl.href); + }; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/Section.tsx b/packages/clerk-js/src/ui-retheme/elements/Section.tsx new file mode 100644 index 0000000000..dd250561e0 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/Section.tsx @@ -0,0 +1,88 @@ +import type { ProfileSectionId } from '@clerk/types'; + +import type { LocalizationKey } from '../customizables'; +import { Col, descriptors, Flex, Text } from '../customizables'; +import type { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; +import type { PropsOfComponent } from '../styledSystem'; + +type ProfileSectionProps = Omit, 'title'> & { + title: LocalizationKey; + subtitle?: LocalizationKey; + id: ProfileSectionId; +}; + +export const ProfileSection = (props: ProfileSectionProps) => { + const { title, children, id, subtitle, ...rest } = props; + return ( + + + {subtitle && ( + + )} + + {children} + + + ); +}; + +type SectionHeaderProps = PropsOfComponent & { + localizationKey: LocalizationKey; + textElementDescriptor?: ElementDescriptor; + textElementId?: ElementId; +}; + +export const SectionHeader = (props: SectionHeaderProps) => { + const { textElementDescriptor, textElementId, localizationKey, ...rest } = props; + return ( + ({ borderBottom: `${theme.borders.$normal} ${theme.colors.$blackAlpha100}` })} + > + + + ); +}; +export const SectionSubHeader = (props: SectionHeaderProps) => { + const { textElementDescriptor, textElementId, localizationKey, ...rest } = props; + return ( + ({ padding: `${t.space.$2} ${t.space.$none}` })} + > + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/Select.tsx b/packages/clerk-js/src/ui-retheme/elements/Select.tsx new file mode 100644 index 0000000000..52813e4f4f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/Select.tsx @@ -0,0 +1,398 @@ +import { createContextAndHook } from '@clerk/shared/react'; +import type { SelectId } from '@clerk/types'; +import type { PropsWithChildren, ReactElement } from 'react'; +import React, { useState } from 'react'; + +import { usePopover, useSearchInput } from '../../ui/hooks'; +import { Button, descriptors, Flex, Icon, Text } from '../customizables'; +import { Caret, MagnifyingGlass } from '../icons'; +import type { PropsOfComponent, ThemableCssProp } from '../styledSystem'; +import { animations, common } from '../styledSystem'; +import { colors } from '../utils'; +import { withFloatingTree } from './contexts'; +import { InputWithIcon } from './InputWithIcon'; +import { Popover } from './Popover'; + +type UsePopoverReturn = ReturnType; +type UseSearchInputReturn = ReturnType; + +type Option = { value: string | null; label?: string }; + +type OptionBuilder = (option: O, index?: number, isFocused?: boolean) => JSX.Element; + +type SelectProps = { + options: O[]; + value: string | null; + onChange: (option: O) => void; + searchPlaceholder?: string; + placeholder?: string; + comparator?: (term: string, option: O) => boolean; + noResultsMessage?: string; + optionBuilder?: OptionBuilder; + elementId?: SelectId; +}; + +type SelectState = Pick< + SelectProps, + 'placeholder' | 'searchPlaceholder' | 'elementId' | 'comparator' | 'noResultsMessage' +> & { + popoverCtx: UsePopoverReturn; + searchInputCtx: UseSearchInputReturn; + optionBuilder: OptionBuilder; + buttonOptionBuilder: OptionBuilder; + selectedOption: Option | null; + select: (option: O) => void; + focusedItemRef: React.RefObject; + onTriggerClick: () => void; +}; + +const [SelectStateCtx, useSelectState] = createContextAndHook>('SelectState'); + +const defaultOptionBuilder = (option: O, _index?: number, isFocused?: boolean) => { + return ( + ({ + width: '100%', + padding: `${theme.space.$2} ${theme.space.$4}`, + margin: `0 ${theme.space.$1}`, + borderRadius: theme.radii.$md, + ...(isFocused && { backgroundColor: theme.colors.$blackAlpha200 }), + '&:hover': { + backgroundColor: theme.colors.$blackAlpha200, + }, + })} + > + {option.label || option.value} + + ); +}; + +const defaultButtonOptionBuilder = (option: O) => { + return <>{option.label || option.value}; +}; + +export const Select = withFloatingTree((props: PropsWithChildren>) => { + const { + value, + options, + onChange, + optionBuilder, + noResultsMessage, + comparator, + placeholder = 'Select an option', + searchPlaceholder, + elementId, + children, + ...rest + } = props; + const popoverCtx = usePopover({ autoUpdate: false, bubbles: false }); + const togglePopover = popoverCtx.toggle; + const focusedItemRef = React.useRef(null); + const searchInputCtx = useSearchInput({ + items: options, + comparator: comparator || (() => true), + }); + + const select = React.useCallback( + (option: O) => { + onChange?.(option); + togglePopover(); + }, + [togglePopover, onChange], + ); + + const defaultChildren = ( + <> + + + + ); + + return ( + o.value === value) || null, + noResultsMessage, + focusedItemRef, + optionBuilder: optionBuilder || defaultOptionBuilder, + buttonOptionBuilder: optionBuilder || defaultButtonOptionBuilder, + placeholder, + searchPlaceholder, + comparator, + select, + onTriggerClick: togglePopover, + elementId, + }, + }} + {...rest} + > + {React.Children.count(children) ? children : defaultChildren} + + ); +}) as (props: PropsWithChildren>) => ReactElement; + +type SelectOptionBuilderProps = { + option: Option; + index: number; + optionBuilder: OptionBuilder; + handleSelect: (option: Option) => void; + isFocused: boolean; + elementId?: SelectId; +}; + +const SelectOptionBuilder = React.memo( + React.forwardRef((props: SelectOptionBuilderProps, ref?: React.ForwardedRef) => { + const { option, optionBuilder, index, handleSelect, isFocused, elementId } = props; + + return ( + { + handleSelect(option); + }} + > + {React.cloneElement(optionBuilder(option, index, isFocused) as React.ReactElement, { + //@ts-expect-error + elementDescriptor: descriptors.selectOption, + elementId: descriptors.selectOption.setId(elementId), + })} + + ); + }), +); + +const SelectSearchbar = (props: PropsOfComponent) => { + const { sx, ...rest } = props; + React.useEffect(() => { + // @ts-expect-error + return () => props.onChange({ target: { value: '' } }); + }, []); + const { elementId } = useSelectState(); + + return ( + ({ borderBottom: theme.borders.$normal, borderColor: theme.colors.$blackAlpha200 })}> + + } + sx={[{ border: 'none', borderRadius: '0' }, sx]} + {...rest} + /> + + ); +}; + +export const SelectNoResults = (props: PropsOfComponent) => { + const { sx, ...rest } = props; + return ( + ({ width: '100%', padding: `${theme.space.$2} 0 0 ${theme.space.$4}` }), sx]} + {...rest} + /> + ); +}; + +type SelectOptionListProps = PropsOfComponent & { + containerSx?: ThemableCssProp; +}; + +export const SelectOptionList = (props: SelectOptionListProps) => { + const { containerSx, sx, ...rest } = props; + const { + popoverCtx, + searchInputCtx, + optionBuilder, + searchPlaceholder, + comparator, + focusedItemRef, + noResultsMessage, + select, + onTriggerClick, + elementId, + } = useSelectState(); + const { filteredItems: options, searchInputProps } = searchInputCtx; + const [focusedIndex, setFocusedIndex] = useState(0); + const { isOpen, floating, styles, nodeId, context } = popoverCtx; + const containerRef = React.useRef(null); + + const scrollToItemOnSelectedIndexChange = () => { + if (!isOpen) { + setFocusedIndex(-1); + return; + } + focusedItemRef.current?.scrollIntoView({ block: 'nearest' }); + }; + + React.useEffect(scrollToItemOnSelectedIndexChange, [focusedIndex, isOpen]); + React.useEffect(() => { + if (!comparator) { + containerRef?.current?.focus(); + } + }, [isOpen]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (isOpen) { + return setFocusedIndex((i = 0) => Math.max(i - 1, 0)); + } + return onTriggerClick(); + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (isOpen) { + return setFocusedIndex((i = 0) => Math.min(i + 1, options.length - 1)); + } + return onTriggerClick(); + } + + if (e.key === 'Enter' && focusedIndex >= 0) { + e.preventDefault(); + return select(options[focusedIndex]); + } + }; + + return ( + + ({ + backgroundColor: colors.makeSolid(theme.colors.$colorBackground), + border: theme.borders.$normal, + borderRadius: theme.radii.$lg, + borderColor: theme.colors.$blackAlpha200, + overflow: 'hidden', + animation: `${animations.dropdownSlideInScaleAndFade} ${theme.transitionDuration.$slower} ${theme.transitionTiming.$slowBezier}`, + transformOrigin: 'top center', + boxShadow: theme.shadows.$cardDropShadow, + zIndex: theme.zIndices.$dropdown, + }), + sx, + ]} + style={{ ...styles, left: styles.left - 1 }} + > + {comparator && ( + + )} + ({ + gap: theme.space.$1, + outline: 'none', + overflowY: 'auto', + maxHeight: '18vh', + padding: `${theme.space.$2} 0`, + }), + containerSx, + ]} + {...rest} + > + {options.map((option, index) => { + const isFocused = index === focusedIndex; + return ( + + ); + })} + {noResultsMessage && options.length === 0 && {noResultsMessage}} + + + + ); +}; + +export const SelectButton = (props: PropsOfComponent) => { + const { sx, children, ...rest } = props; + const { popoverCtx, onTriggerClick, buttonOptionBuilder, selectedOption, placeholder, elementId } = useSelectState(); + const { isOpen, reference } = popoverCtx; + + let show: React.ReactNode = children; + if (!children) { + show = selectedOption ? ( + buttonOptionBuilder(selectedOption) + ) : ( + ({ opacity: t.opacity.$inactive })}>{placeholder} + ); + } + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/SocialButtons.tsx b/packages/clerk-js/src/ui-retheme/elements/SocialButtons.tsx new file mode 100644 index 0000000000..a267a383a1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/SocialButtons.tsx @@ -0,0 +1,174 @@ +import type { OAuthProvider, OAuthStrategy, Web3Provider, Web3Strategy } from '@clerk/types'; +import React from 'react'; + +import { Button, descriptors, Grid, Image, localizationKeys, useAppearance } from '../customizables'; +import { useEnabledThirdPartyProviders } from '../hooks'; +import type { PropsOfComponent } from '../styledSystem'; +import { sleep } from '../utils'; +import { ArrowBlockButton } from './ArrowBlockButton'; +import { useCardState } from './contexts'; + +const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; + +export type SocialButtonsProps = React.PropsWithChildren<{ + enableOAuthProviders: boolean; + enableWeb3Providers: boolean; +}>; + +type SocialButtonsRootProps = SocialButtonsProps & { + oauthCallback: (strategy: OAuthStrategy) => Promise; + web3Callback: (strategy: Web3Strategy) => Promise; +}; + +const isWeb3Strategy = (val: string): val is Web3Strategy => { + return val.startsWith('web3_'); +}; + +export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { + const { oauthCallback, web3Callback, enableOAuthProviders = true, enableWeb3Providers = true } = props; + const { web3Strategies, authenticatableOauthStrategies, strategyToDisplayData } = useEnabledThirdPartyProviders(); + const card = useCardState(); + const { socialButtonsVariant } = useAppearance().parsedLayout; + + const strategies = [ + ...(enableOAuthProviders ? authenticatableOauthStrategies : []), + ...(enableWeb3Providers ? web3Strategies : []), + ]; + + if (!strategies.length) { + return null; + } + + const preferBlockButtons = + socialButtonsVariant === 'blockButton' + ? true + : socialButtonsVariant === 'iconButton' + ? false + : strategies.length <= SOCIAL_BUTTON_BLOCK_THRESHOLD; + + const startOauth = (strategy: OAuthStrategy | Web3Strategy) => async () => { + card.setLoading(strategy); + try { + if (isWeb3Strategy(strategy)) { + await web3Callback(strategy); + } else { + await oauthCallback(strategy); + } + } catch { + await sleep(1000); + card.setIdle(); + } + await sleep(5000); + card.setIdle(); + }; + + const ButtonElement = preferBlockButtons ? SocialButtonBlock : SocialButtonIcon; + const WrapperElement = preferBlockButtons ? ButtonRows : ButtonGrid; + + return ( + + {strategies.map(strategy => ( + ({ width: theme.sizes.$5, height: 'auto', maxWidth: '100%' })} + /> + } + /> + ))} + + ); +}); + +const ButtonGrid = (props: React.PropsWithChildren) => { + return ( + ({ + gridTemplateColumns: `repeat(auto-fit, minmax(${t.sizes.$12}, 1fr))`, + gridAutoRows: t.sizes.$12, + })} + > + {props.children} + + ); +}; + +const ButtonRows = (props: React.PropsWithChildren) => { + return ( + + {props.children} + + ); +}; + +type SocialButtonProps = PropsOfComponent & { + icon: React.ReactElement; + id: OAuthProvider | Web3Provider; + providerName: string; + label?: string; +}; + +const SocialButtonIcon = (props: SocialButtonProps): JSX.Element => { + const { icon, label, id, providerName, ...rest } = props; + return ( + + ); +}; + +const SocialButtonBlock = (props: SocialButtonProps): JSX.Element => { + const { label, id, providerName, sx, icon, ...rest } = props; + + return ( + + {label} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/SuccessPage.tsx b/packages/clerk-js/src/ui-retheme/elements/SuccessPage.tsx new file mode 100644 index 0000000000..a97449e4f0 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/SuccessPage.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { Box, descriptors, Text } from '../customizables'; +import { ContentPage, FormButtonContainer, NavigateToFlowStartButton } from '../elements'; +import type { LocalizationKey } from '../localization'; +import { localizationKeys } from '../localization'; +import type { PropsOfComponent } from '../styledSystem'; + +type SuccessPageProps = Omit, 'headerTitle' | 'title'> & { + title: LocalizationKey; + text?: LocalizationKey | LocalizationKey[]; + finishLabel?: LocalizationKey; + contents?: React.ReactNode; + onFinish?: () => void; +}; + +export const SuccessPage = (props: SuccessPageProps) => { + const { text, title, finishLabel, onFinish, contents, ...rest } = props; + + return ( + + + {Array.isArray(text) ? ( + text.map(t => ( + ({ + display: 'inline', + ':not(:last-of-type)': { + marginRight: t.sizes.$1, + }, + })} + /> + )) + ) : ( + + )} + + {contents} + + + + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/Tabs.tsx b/packages/clerk-js/src/ui-retheme/elements/Tabs.tsx new file mode 100644 index 0000000000..4b38e24482 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/Tabs.tsx @@ -0,0 +1,201 @@ +import { createContextAndHook } from '@clerk/shared/react'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +import { Button, descriptors, Flex, useLocalizations } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; +import { getValidChildren } from '../utils'; + +type TabsContextValue = { + selectedIndex: number; + setSelectedIndex: (item: number) => void; + focusedIndex: number; + setFocusedIndex: (item: number) => void; +}; + +const [TabsContext, useTabsContext] = createContextAndHook('TabsContext'); +const TabsContextProvider = (props: React.PropsWithChildren<{ value: TabsContextValue }>) => { + const ctxValue = React.useMemo( + () => ({ value: props.value }), + [props.value.selectedIndex, props.value.setFocusedIndex], + ); + return {props.children}; +}; + +type TabsProps = PropsWithChildren<{ + defaultIndex?: number; +}>; + +export const Tabs = (props: TabsProps) => { + const { defaultIndex = 0, children } = props; + const [selectedIndex, setSelectedIndex] = React.useState(defaultIndex); + const [focusedIndex, setFocusedIndex] = React.useState(-1); + + return ( + + {children} + + ); +}; + +type TabsListProps = PropsOfComponent; +export const TabsList = (props: TabsListProps) => { + const { children, sx, ...rest } = props; + const { setSelectedIndex, selectedIndex, setFocusedIndex } = useTabsContext(); + + const childrenWithProps = getValidChildren(children).map((child, index) => + React.cloneElement(child, { + tabIndex: index, + }), + ); + + const onKeyDown = (e: React.KeyboardEvent) => { + const tabs = childrenWithProps.filter(child => !child.props?.isDisabled).map(child => child.props.tabIndex); + const tabsLenth = tabs.length; + const current = tabs.indexOf(selectedIndex); + + if (e.key === 'ArrowLeft') { + const prev = current === 0 ? tabs[tabsLenth - 1] : tabs[current - 1]; + setFocusedIndex(prev); + return setSelectedIndex(prev); + } + if (e.key === 'ArrowRight') { + const next = tabsLenth - 1 === current ? tabs[0] : tabs[current + 1]; + setFocusedIndex(next); + return setSelectedIndex(next); + } + }; + + return ( + ({ + borderBottom: theme.borders.$normal, + borderColor: theme.colors.$blackAlpha300, + }), + sx, + ]} + {...rest} + > + {childrenWithProps} + + ); +}; + +type TabProps = PropsOfComponent; +type TabPropsWithTabIndex = TabProps & { tabIndex?: number }; +export const Tab = (props: TabProps) => { + const { t } = useLocalizations(); + const { children, sx, tabIndex, isDisabled, localizationKey, ...rest } = props as TabPropsWithTabIndex; + + if (tabIndex === undefined) { + throw new Error('Tab component must be a direct child of TabList.'); + } + + const { setSelectedIndex, selectedIndex, focusedIndex, setFocusedIndex } = useTabsContext(); + const buttonRef = React.useRef(null); + const isActive = tabIndex === selectedIndex; + const isFocused = tabIndex === focusedIndex; + + const onClick = () => { + setSelectedIndex(tabIndex); + setFocusedIndex(-1); + }; + + React.useEffect(() => { + if (isDisabled && tabIndex === 0) { + setSelectedIndex((tabIndex as number) + 1); + } + }, []); + + React.useEffect(() => { + if (buttonRef.current && isFocused) { + buttonRef.current.focus(); + } + }, [isFocused]); + + return ( + + ); +}; + +export const TabPanels = (props: PropsWithChildren>) => { + const { children } = props; + + const childrenWithProps = getValidChildren(children).map((child, index) => + React.cloneElement(child, { + tabIndex: index, + }), + ); + + return <>{childrenWithProps}; +}; + +type TabPanelProps = PropsOfComponent; +type TabPanelPropsWithTabIndex = TabPanelProps & { tabIndex?: number }; +export const TabPanel = (props: TabPanelProps) => { + const { tabIndex, sx, children, ...rest } = props as TabPanelPropsWithTabIndex; + + if (tabIndex === undefined) { + throw new Error('TabPanel component must be a direct child of TabPanels.'); + } + + const { selectedIndex } = useTabsContext(); + const isOpen = tabIndex === selectedIndex; + + if (!isOpen) { + return null; + } + + return ( + + {children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/TagInput.tsx b/packages/clerk-js/src/ui-retheme/elements/TagInput.tsx new file mode 100644 index 0000000000..17cb0a9e9f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/TagInput.tsx @@ -0,0 +1,206 @@ +import React from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { descriptors, Flex, Icon, Input, Text, useLocalizations } from '../customizables'; +import { Plus } from '../icons'; +import type { PropsOfComponent } from '../styledSystem'; +import { common } from '../styledSystem'; + +type Tag = string; + +const sanitize = (val: string) => val.trim(); + +type TagInputProps = Pick, 'sx'> & { + value: string; + onChange: React.ChangeEventHandler; + validate?: (tag: Tag) => boolean; + placeholder?: LocalizationKey | string; + autoFocus?: boolean; + validateUnsubmittedEmail?: (value: string) => void; +}; + +export const TagInput = (props: TagInputProps) => { + const { t } = useLocalizations(); + const { + sx, + placeholder, + validate = () => true, + value: valueProp, + onChange: onChangeProp, + autoFocus, + validateUnsubmittedEmail = () => null, + ...rest + } = props; + const tags = valueProp.split(',').map(sanitize).filter(Boolean); + const tagsSet = new Set(tags); + const keyReleasedRef = React.useRef(true); + const inputRef = React.useRef(null); + const [input, setInput] = React.useState(''); + + const emit = (newTags: Tag[]) => { + onChangeProp({ target: { value: newTags.join(',') } } as any); + focusInput(); + validateUnsubmittedEmail(''); + }; + + const remove = (tag: Tag) => { + emit(tags.filter(t => t !== tag)); + }; + + const removeLast = () => { + emit(tags.slice(0, -1)); + }; + + const addTag = (tag: Tag | Tag[]) => { + // asdfa@asd.com + const newTags = (Array.isArray(tag) ? [...tag] : [tag]) + .map(sanitize) + .filter(Boolean) + .filter(validate) + .filter(t => !tagsSet.has(t)); + + if (newTags.length) { + emit([...tags, ...newTags]); + setInput(''); + focusInput(); + } + }; + + const focusInput = () => { + inputRef.current?.focus(); + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + const { key } = e; + if ((key === ',' || key === ' ' || key === 'Enter') && !!input.length) { + e.preventDefault(); + addTag(input); + } else if (key === 'Backspace' && !input.length && !!tags.length && keyReleasedRef.current) { + e.preventDefault(); + removeLast(); + } + keyReleasedRef.current = false; + }; + + const handleOnBlur: React.FocusEventHandler = e => { + e.preventDefault(); + addTag(input); + }; + + const handleKeyUp: React.KeyboardEventHandler = () => { + // If the user is holding backspace down, we want to clear the input + // but not start deleting previous tags. So we wait for them to release the + // backspace key, before allowing the deletion of the previously set tag + keyReleasedRef.current = true; + }; + + const handleChange: React.ChangeEventHandler = e => { + setInput(e.target.value); + validateUnsubmittedEmail(e.target.value); + }; + + const handlePaste = (e: React.ClipboardEvent) => { + e.preventDefault(); + addTag( + (e.clipboardData.getData('text') || '') + .split(/,| |\n|\t/) + .filter(Boolean) + .map(tag => tag.trim()), + ); + }; + + return ( + ({ + maxWidth: '100%', + padding: `${t.space.$2x5} ${t.space.$4}`, + backgroundColor: t.colors.$colorInputBackground, + color: t.colors.$colorInputText, + minHeight: t.sizes.$20, + maxHeight: t.sizes.$60, + overflowY: 'auto', + ...common.borderVariants(t).normal, + }), + sx, + ]} + {...rest} + > + {tags.map(tag => ( + remove(tag)} + > + {tag} + + ))} + + ({ + flexGrow: 1, + border: 'none', + width: 'initial', + padding: 0, + lineHeight: t.space.$6, + paddingLeft: t.space.$1, + })} + /> + + ); +}; + +type TagPillProps = React.PropsWithChildren<{ onRemoveClick: React.MouseEventHandler }>; +const TagPill = (props: TagPillProps) => { + const { onRemoveClick, children, ...rest } = props; + + return ( + ({ + padding: `${t.space.$1x5} ${t.space.$3}`, + backgroundColor: t.colors.$blackAlpha50, + borderRadius: t.radii.$sm, + cursor: 'pointer', + ':hover svg': { + color: t.colors.$danger500, + }, + overflow: 'hidden', + })} + > + + {children} + + ({ color: t.colors.$blackAlpha500, transform: 'translateY(1px) rotate(45deg)' })} + /> + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/ThreeDotsMenu.tsx b/packages/clerk-js/src/ui-retheme/elements/ThreeDotsMenu.tsx new file mode 100644 index 0000000000..b1c2d08479 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/ThreeDotsMenu.tsx @@ -0,0 +1,52 @@ +import type { MenuId } from '@clerk/types'; + +import type { LocalizationKey } from '../customizables'; +import { Button, Icon } from '../customizables'; +import { ThreeDots } from '../icons'; +import { Menu, MenuItem, MenuList, MenuTrigger } from './Menu'; + +type Action = { + label: LocalizationKey; + isDestructive?: boolean; + onClick: () => unknown; + isDisabled?: boolean; +}; + +type ThreeDotsMenuProps = { + actions: Action[]; + elementId?: MenuId; +}; + +export const ThreeDotsMenu = (props: ThreeDotsMenuProps) => { + const { actions, elementId } = props; + return ( + + + + + + {actions.map((a, index) => ( + + ))} + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/TileButton.tsx b/packages/clerk-js/src/ui-retheme/elements/TileButton.tsx new file mode 100644 index 0000000000..27c64288af --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/TileButton.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import { Button, Col, Icon, Text } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; + +export const TileButton = (props: PropsOfComponent & { icon: React.ComponentType }) => { + const { icon, ...rest } = props; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/TimerButton.tsx b/packages/clerk-js/src/ui-retheme/elements/TimerButton.tsx new file mode 100644 index 0000000000..eebcb538cf --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/TimerButton.tsx @@ -0,0 +1,68 @@ +import { useSafeLayoutEffect } from '@clerk/shared/react'; +import React, { useEffect } from 'react'; + +import { Button, useLocalizations } from '../customizables'; +import type { PropsOfComponent } from '../styledSystem'; + +type TimerButtonProps = PropsOfComponent & { + throttleTimeInSec?: number; + startDisabled?: boolean; + showCounter?: boolean; +}; + +export const TimerButton = (props: TimerButtonProps) => { + const { t } = useLocalizations(); + const { + onClick: onClickProp, + throttleTimeInSec = 30, + startDisabled, + children, + localizationKey, + showCounter = true, + ...rest + } = props; + const [remainingSeconds, setRemainingSeconds] = React.useState(0); + const intervalIdRef = React.useRef(undefined); + + useSafeLayoutEffect(() => { + if (startDisabled) { + disable(); + } + }, []); + + useEffect(() => { + return () => clearInterval(intervalIdRef.current); + }, []); + + const disable = () => { + setRemainingSeconds(throttleTimeInSec); + intervalIdRef.current = window.setInterval(() => { + setRemainingSeconds(seconds => { + if (seconds === 1) { + clearInterval(intervalIdRef.current); + } + return seconds - 1; + }); + }, 1000); + }; + + const handleOnClick: React.MouseEventHandler = e => { + if (remainingSeconds) { + return; + } + onClickProp?.(e); + disable(); + }; + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/UserAvatar.tsx b/packages/clerk-js/src/ui-retheme/elements/UserAvatar.tsx new file mode 100644 index 0000000000..e94f286aef --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/UserAvatar.tsx @@ -0,0 +1,27 @@ +import type { UserResource } from '@clerk/types'; + +import { getFullName, getInitials } from '../../utils/user'; +import { Avatar } from '../elements'; +import type { PropsOfComponent } from '../styledSystem'; + +type UserAvatarProps = Omit, 'imageUrl'> & + Partial> & { + name?: string | null; + avatarUrl?: string | null; + }; + +export const UserAvatar = (props: UserAvatarProps) => { + //TODO: replace profileImageUrl with imageUrl + const { name, firstName, lastName, avatarUrl, imageUrl, ...rest } = props; + const generatedName = getFullName({ name, firstName, lastName }); + const initials = getInitials({ name, firstName, lastName }); + + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/UserPreview.tsx b/packages/clerk-js/src/ui-retheme/elements/UserPreview.tsx new file mode 100644 index 0000000000..e1f745ceac --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/UserPreview.tsx @@ -0,0 +1,158 @@ +import type { ExternalAccountResource, SamlAccountResource, UserPreviewId, UserResource } from '@clerk/types'; +import React from 'react'; + +import { getFullName, getIdentifier } from '../../utils/user'; +import type { LocalizationKey } from '../customizables'; +import { descriptors, Flex, Text, useLocalizations } from '../customizables'; +import type { InternalTheme, PropsOfComponent, ThemableCssProp } from '../styledSystem'; +import { UserAvatar } from './UserAvatar'; + +// TODO Make this accept an interface with the superset of fields in: +// - User +// - ExternalAccountResource +// - SamlAccountResource + +export type UserPreviewProps = Omit, 'title' | 'elementId'> & { + size?: 'lg' | 'md' | 'sm'; + icon?: React.ReactNode; + badge?: React.ReactNode; + imageUrl?: string | null; + rounded?: boolean; + elementId?: UserPreviewId; + avatarSx?: ThemableCssProp; + mainIdentifierSx?: ThemableCssProp; + title?: LocalizationKey | string; + subtitle?: LocalizationKey | string; + showAvatar?: boolean; +} & ( + | { + user?: Partial; + externalAccount?: null | undefined; + samlAccount?: null | undefined; + } + | { + user?: null | undefined; + externalAccount?: Partial; + samlAccount?: null | undefined; + } + | { + user?: null | undefined; + externalAccount?: null | undefined; + samlAccount?: Partial; + } + ); + +export const UserPreview = (props: UserPreviewProps) => { + const { + user, + externalAccount, + samlAccount, + size = 'md', + showAvatar = true, + icon, + rounded = true, + imageUrl: imageUrlProp, + badge, + elementId, + sx, + title, + subtitle, + avatarSx, + mainIdentifierSx, + ...rest + } = props; + const { t } = useLocalizations(); + const name = getFullName({ ...user }) || getFullName({ ...externalAccount }) || getFullName({ ...samlAccount }); + const identifier = getIdentifier({ ...user }) || externalAccount?.accountIdentifier?.() || samlAccount?.emailAddress; + const localizedTitle = t(title); + + const imageUrl = imageUrlProp || user?.imageUrl || externalAccount?.imageUrl; + + const getAvatarSizes = (t: InternalTheme) => ({ sm: t.sizes.$8, md: t.sizes.$11, lg: t.sizes.$12x5 }[size]); + + return ( + + {/*Do not attempt to render or reserve space based on height if image url is not defined*/} + {imageUrl ? ( + showAvatar ? ( + + + + {icon && {icon}} + + ) : ( + // Reserve layout space when avatar is not visible + ({ + height: getAvatarSizes(t), + })} + /> + ) + ) : null} + + + ({ display: 'flex', gap: theme.sizes.$1, alignItems: 'center' }), mainIdentifierSx]} + > + + {localizedTitle || name || identifier} + + + {badge} + + + {(subtitle || (name && identifier)) && ( + + )} + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/VerificationCodeCard.tsx b/packages/clerk-js/src/ui-retheme/elements/VerificationCodeCard.tsx new file mode 100644 index 0000000000..be4f1eeeed --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/VerificationCodeCard.tsx @@ -0,0 +1,112 @@ +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +import { Col, descriptors, localizationKeys } from '../customizables'; +import { useLoadingStatus } from '../hooks'; +import type { LocalizationKey } from '../localization'; +import { handleError, sleep, useFormControl } from '../utils'; +import { CardAlert } from './Alert'; +import { Card } from './Card'; +import { useCodeControl } from './CodeControl'; +import { CodeForm } from './CodeForm'; +import { useCardState } from './contexts'; +import { Footer } from './Footer'; +import { Header } from './Header'; +import { IdentityPreview } from './IdentityPreview'; + +export type VerificationCodeCardProps = { + cardTitle: LocalizationKey; + cardSubtitle: LocalizationKey; + formTitle: LocalizationKey; + formSubtitle: LocalizationKey; + safeIdentifier?: string | undefined | null; + resendButton?: LocalizationKey; + profileImageUrl?: string; + onCodeEntryFinishedAction: ( + code: string, + resolve: () => Promise, + reject: (err: unknown) => Promise, + ) => void; + onResendCodeClicked?: React.MouseEventHandler; + showAlternativeMethods?: boolean; + onShowAlternativeMethodsClicked?: React.MouseEventHandler; + onIdentityPreviewEditClicked?: React.MouseEventHandler; + onBackLinkClicked?: React.MouseEventHandler; +}; + +export const VerificationCodeCard = (props: PropsWithChildren) => { + const { showAlternativeMethods = true, children } = props; + const [success, setSuccess] = React.useState(false); + const status = useLoadingStatus(); + const codeControlState = useFormControl('code', ''); + const codeControl = useCodeControl(codeControlState); + const card = useCardState(); + + const resolve = async () => { + setSuccess(true); + await sleep(750); + }; + + const reject = async (err: any) => { + handleError(err, [codeControlState], card.setError); + status.setIdle(); + await sleep(750); + codeControl.reset(); + }; + + codeControl.onCodeEntryFinished(code => { + status.setLoading(); + codeControlState.setError(undefined); + props.onCodeEntryFinishedAction(code, resolve, reject); + }); + + const handleResend = props.onResendCodeClicked + ? (e: React.MouseEvent) => { + codeControl.reset(); + props.onResendCodeClicked?.(e); + } + : undefined; + + return ( + + {card.error} + + {props.onBackLinkClicked && } + + + + {children} + + + + + + {showAlternativeMethods && props.onShowAlternativeMethodsClicked && ( + + + + + + + )} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/VerificationLinkCard.tsx b/packages/clerk-js/src/ui-retheme/elements/VerificationLinkCard.tsx new file mode 100644 index 0000000000..2f76e80a7d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/VerificationLinkCard.tsx @@ -0,0 +1,115 @@ +import React from 'react'; + +import type { LocalizationKey } from '../customizables'; +import { Col, descriptors, Flow, localizationKeys, Text } from '../customizables'; +import { useRouter } from '../router'; +import { CardAlert } from './Alert'; +import { Card } from './Card'; +import { useCardState } from './contexts'; +import { Footer } from './Footer'; +import { Header } from './Header'; +import { IdentityPreview } from './IdentityPreview'; +import { TimerButton } from './TimerButton'; + +type VerificationLinkCardProps = { + safeIdentifier: string | undefined | null; + cardTitle: LocalizationKey; + cardSubtitle: LocalizationKey; + formTitle: LocalizationKey; + formSubtitle: LocalizationKey; + resendButton: LocalizationKey; + profileImageUrl?: string; + onResendCodeClicked: React.MouseEventHandler; + onShowAlternativeMethodsClicked?: React.MouseEventHandler; +}; + +export const VerificationLinkCard = (props: VerificationLinkCardProps) => { + const { navigate } = useRouter(); + const card = useCardState(); + + const goBack = () => { + return navigate('../'); + }; + + return ( + + + {card.error} + + + + + + + + + + + {props.onShowAlternativeMethodsClicked && ( + + )} + + + + + + ); +}; + +type VerificationLinkProps = { + formTitle: LocalizationKey; + formSubtitle: LocalizationKey; + resendButton: LocalizationKey; + onResendCodeClicked: React.MouseEventHandler; +}; + +export const VerificationLink = (props: VerificationLinkProps) => { + const card = useCardState(); + return ( + + + + + + ({ marginTop: theme.space.$4 })} + /> + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/__tests__/PlainInput.test.tsx b/packages/clerk-js/src/ui-retheme/elements/__tests__/PlainInput.test.tsx new file mode 100644 index 0000000000..de6c28a21f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/__tests__/PlainInput.test.tsx @@ -0,0 +1,317 @@ +import { describe, it } from '@jest/globals'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useFormControl } from '../../utils'; +import { bindCreateFixtures } from '../../utils/test/createFixtures'; +import { withCardStateProvider } from '../contexts'; +import { Form } from '../Form'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); +const createField = (...params: Parameters) => { + const MockFieldWrapper = withCardStateProvider((props: Partial[0]>) => { + const field = useFormControl(...params); + + return ( + <> + {/* @ts-ignore*/} + + + + ); + }); + + return { + Field: MockFieldWrapper, + }; +}; + +// TODO: Remove this once FormControl is no longer used +const createFormControl = (...params: Parameters) => { + const MockFieldWrapper = withCardStateProvider((props: Partial[0]>) => { + const field = useFormControl(...params); + + return ( + <> + {/* @ts-ignore*/} + + + + ); + }); + + return { + Field: MockFieldWrapper, + }; +}; + +describe('PlainInput', () => { + it('renders the component', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText } = render(, { wrapper }); + expect(getByLabelText('some label')).toHaveValue('init value'); + expect(getByLabelText('some label')).toHaveAttribute('name', 'firstname'); + expect(getByLabelText('some label')).toHaveAttribute('placeholder', 'some placeholder'); + expect(getByLabelText('some label')).toHaveAttribute('type', 'text'); + expect(getByLabelText('some label')).toHaveAttribute('id', 'firstname-field'); + expect(getByLabelText('some label')).not.toHaveAttribute('disabled'); + expect(getByLabelText('some label')).not.toHaveAttribute('required'); + expect(getByLabelText('some label')).toHaveAttribute('aria-invalid', 'false'); + expect(getByLabelText('some label')).toHaveAttribute('aria-describedby', ''); + expect(getByLabelText('some label')).toHaveAttribute('aria-required', 'false'); + expect(getByLabelText('some label')).toHaveAttribute('aria-disabled', 'false'); + }); + + it('disabled', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText } = render(, { wrapper }); + expect(getByLabelText('some label')).toHaveValue('init value'); + expect(getByLabelText('some label')).toHaveAttribute('disabled'); + expect(getByLabelText('some label')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('required', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText, queryByText } = render(, { wrapper }); + expect(getByLabelText('some label')).toHaveValue('init value'); + expect(getByLabelText('some label')).toHaveAttribute('required'); + expect(getByLabelText('some label')).toHaveAttribute('aria-required', 'true'); + expect(queryByText(/optional/i)).not.toBeInTheDocument(); + }); + + it('optional', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + expect(getByLabelText('some label')).not.toHaveAttribute('required'); + expect(getByLabelText('some label')).toHaveAttribute('aria-required', 'false'); + expect(getByText(/optional/i)).toBeInTheDocument(); + }); + + it('with icon', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const Icon = () => this is an icon; + + const { getByAltText } = render(, { wrapper }); + expect(getByAltText(/this is an icon/i)).toBeInTheDocument(); + }); + + it('with action label', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByRole } = render(, { wrapper }); + expect(getByRole('link', { name: /take action/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /take action/i })).not.toHaveAttribute('rel'); + expect(getByRole('link', { name: /take action/i })).not.toHaveAttribute('target'); + expect(getByRole('link', { name: /take action/i })).toHaveAttribute('href', ''); + }); + + it('with error', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByRole, getByLabelText, getByText } = render(, { wrapper }); + + await act(() => userEvent.click(getByRole('button', { name: /set error/i }))); + + await waitFor(() => { + expect(getByLabelText('some label')).toHaveAttribute('aria-invalid', 'true'); + expect(getByLabelText('some label')).toHaveAttribute('aria-describedby', 'error-firstname'); + expect(getByText('some error')).toBeInTheDocument(); + }); + }); + + it('with info', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + infoText: 'some info', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + await act(() => fireEvent.focus(getByLabelText('some label'))); + await waitFor(() => { + expect(getByText('some info')).toBeInTheDocument(); + }); + }); +}); + +/** + * This tests ensure that the deprecated FormControl and PlainInput continue to behave the same and nothing broke during the refactoring. + */ +describe('Form control as text', () => { + it('renders the component', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText } = render(, { wrapper }); + expect(getByLabelText('some label')).toHaveValue('init value'); + expect(getByLabelText('some label')).toHaveAttribute('name', 'firstname'); + expect(getByLabelText('some label')).toHaveAttribute('placeholder', 'some placeholder'); + expect(getByLabelText('some label')).toHaveAttribute('type', 'text'); + expect(getByLabelText('some label')).toHaveAttribute('id', 'firstname-field'); + expect(getByLabelText('some label')).not.toHaveAttribute('disabled'); + expect(getByLabelText('some label')).not.toHaveAttribute('required'); + expect(getByLabelText('some label')).toHaveAttribute('aria-invalid', 'false'); + expect(getByLabelText('some label')).toHaveAttribute('aria-describedby', ''); + expect(getByLabelText('some label')).toHaveAttribute('aria-required', 'false'); + expect(getByLabelText('some label')).toHaveAttribute('aria-disabled', 'false'); + }); + + it('disabled', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText } = render(, { wrapper }); + expect(getByLabelText('some label')).toHaveValue('init value'); + expect(getByLabelText('some label')).toHaveAttribute('disabled'); + expect(getByLabelText('some label')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('required', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText, queryByText } = render(, { wrapper }); + expect(getByLabelText('some label')).toHaveValue('init value'); + expect(getByLabelText('some label')).toHaveAttribute('required'); + expect(getByLabelText('some label')).toHaveAttribute('aria-required', 'true'); + expect(queryByText(/optional/i)).not.toBeInTheDocument(); + }); + + it('optional', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + expect(getByLabelText('some label')).not.toHaveAttribute('required'); + expect(getByLabelText('some label')).toHaveAttribute('aria-required', 'false'); + expect(getByText(/optional/i)).toBeInTheDocument(); + }); + + it('with icon', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const Icon = () => this is an icon; + + const { getByAltText } = render(, { wrapper }); + expect(getByAltText(/this is an icon/i)).toBeInTheDocument(); + }); + + it('with action label', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByRole } = render(, { wrapper }); + expect(getByRole('link', { name: /take action/i })).toBeInTheDocument(); + expect(getByRole('link', { name: /take action/i })).not.toHaveAttribute('rel'); + expect(getByRole('link', { name: /take action/i })).not.toHaveAttribute('target'); + expect(getByRole('link', { name: /take action/i })).toHaveAttribute('href', ''); + }); + + it('with error', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByRole, getByLabelText, getByText } = render(, { wrapper }); + + await act(() => userEvent.click(getByRole('button', { name: /set error/i }))); + + await waitFor(() => { + expect(getByLabelText('some label')).toHaveAttribute('aria-invalid', 'true'); + expect(getByLabelText('some label')).toHaveAttribute('aria-describedby', 'error-firstname'); + expect(getByText('some error')).toBeInTheDocument(); + }); + }); + + it('with info', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + infoText: 'some info', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + await act(() => fireEvent.focus(getByLabelText('some label'))); + await waitFor(() => { + expect(getByText('some info')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/__tests__/RadioGroup.test.tsx b/packages/clerk-js/src/ui-retheme/elements/__tests__/RadioGroup.test.tsx new file mode 100644 index 0000000000..aafcca099d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/__tests__/RadioGroup.test.tsx @@ -0,0 +1,351 @@ +import { describe, it } from '@jest/globals'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { useFormControl } from '../../utils'; +import { bindCreateFixtures } from '../../utils/test/createFixtures'; +import { withCardStateProvider } from '../contexts'; +import { Form } from '../Form'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); +const createField = (...params: Parameters) => { + const MockFieldWrapper = withCardStateProvider((props: Partial[0]>) => { + const field = useFormControl(...params); + + return ( + <> + {/* @ts-ignore*/} + + + + ); + }); + + return { + Field: MockFieldWrapper, + }; +}; + +// TODO: Remove this once FormControl is no longer used +const createFormControl = (...params: Parameters) => { + const MockFieldWrapper = withCardStateProvider((props: Partial[0]>) => { + const field = useFormControl(...params); + + return ( + <> + {/* @ts-ignore*/} + + + + ); + }); + + return { + Field: MockFieldWrapper, + }; +}; + +describe('RadioGroup', () => { + it('renders the component', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', ''); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).not.toBeChecked(); + expect(radio).toHaveAttribute('name', 'some-radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + }); + + expect(radios[1]).not.toBeChecked(); + }); + + it('renders the component with default value', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).toHaveAttribute('type', 'radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-required', 'false'); + expect(radio).toHaveAttribute('aria-disabled', 'false'); + }); + + expect(radios[0]).not.toBeChecked(); + expect(radios[1]).toBeChecked(); + }); + + it('disabled', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).not.toHaveAttribute('required'); + expect(radio).toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('required', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole } = render( + , + { wrapper }, + ); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('required'); + expect(radio).toHaveAttribute('aria-required', 'true'); + }); + }); + + it('with error', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', 'two'); + + const { getAllByRole, getByRole, getByText } = render( + , + { wrapper }, + ); + + await act(() => userEvent.click(getByRole('button', { name: /set error/i }))); + + await waitFor(() => { + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('aria-invalid', 'true'); + expect(radio).toHaveAttribute('aria-describedby', 'error-some-radio'); + }); + expect(getByText('some error')).toBeInTheDocument(); + }); + }); + + it('with info', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('some-radio', '', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + infoText: 'some info', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + + await act(() => fireEvent.focus(getByLabelText('One'))); + await waitFor(() => { + expect(getByText('some info')).toBeInTheDocument(); + }); + }); +}); + +/** + * This tests ensure that the deprecated FormControl and RadioGroup continue to behave the same and nothing broke during the refactoring. + */ +describe('Form control as text', () => { + it('renders the component', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', '', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).not.toBeChecked(); + expect(radio).toHaveAttribute('name', 'some-radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + }); + + expect(radios[1]).not.toBeChecked(); + }); + + it('renders the component with default value', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + expect(radios[0]).toHaveAttribute('value', 'one'); + expect(radios[0].nextSibling).toHaveTextContent('One'); + expect(radios[1]).toHaveAttribute('value', 'two'); + expect(radios[1].nextSibling).toHaveTextContent('Two'); + + radios.forEach(radio => { + expect(radio).toHaveAttribute('type', 'radio'); + expect(radio).not.toHaveAttribute('required'); + expect(radio).not.toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-required', 'false'); + expect(radio).toHaveAttribute('aria-disabled', 'false'); + }); + + expect(radios[0]).not.toBeChecked(); + expect(radios[1]).toBeChecked(); + }); + + it('disabled', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).not.toHaveAttribute('required'); + expect(radio).toHaveAttribute('disabled'); + expect(radio).toHaveAttribute('aria-disabled', 'true'); + }); + }); + + it('required', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole } = render(, { wrapper }); + + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('required'); + expect(radio).toHaveAttribute('aria-required', 'true'); + }); + }); + + it('with error', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', 'two', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + }); + + const { getAllByRole, getByRole, getByText } = render(, { wrapper }); + + await act(() => userEvent.click(getByRole('button', { name: /set error/i }))); + + await waitFor(() => { + const radios = getAllByRole('radio'); + radios.forEach(radio => { + expect(radio).toHaveAttribute('aria-invalid', 'true'); + expect(radio).toHaveAttribute('aria-describedby', 'error-some-radio'); + }); + expect(getByText('some error')).toBeInTheDocument(); + }); + }); + + it('with info', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createFormControl('some-radio', '', { + type: 'radio', + radioOptions: [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + ], + infoText: 'some info', + }); + + const { getByLabelText, getByText } = render(, { wrapper }); + + await act(() => fireEvent.focus(getByLabelText('One'))); + await waitFor(() => { + expect(getByText('some info')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/elements/contexts/CardStateContext.tsx b/packages/clerk-js/src/ui-retheme/elements/contexts/CardStateContext.tsx new file mode 100644 index 0000000000..d974c12402 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/contexts/CardStateContext.tsx @@ -0,0 +1,71 @@ +import { createContextAndHook } from '@clerk/shared/react'; +import type { ClerkAPIError, ClerkRuntimeError } from '@clerk/types'; +import React from 'react'; + +import { useLocalizations } from '../../customizables'; +import { useSafeState } from '../../hooks'; + +type Status = 'idle' | 'loading' | 'error'; +type Metadata = string | undefined; +type State = { status: Status; metadata: Metadata; error: string | undefined }; +type CardStateCtxValue = { + state: State; + setState: React.Dispatch>; +}; + +const [CardStateCtx, _useCardState] = createContextAndHook('CardState'); + +const CardStateProvider = (props: React.PropsWithChildren) => { + const { translateError } = useLocalizations(); + + const [state, setState] = useSafeState({ + status: 'idle', + metadata: undefined, + error: translateError(window?.Clerk?.__internal_last_error || undefined), + }); + + const value = React.useMemo(() => ({ value: { state, setState } }), [state, setState]); + return {props.children}; +}; + +const useCardState = () => { + const { state, setState } = _useCardState(); + const { translateError } = useLocalizations(); + + const setIdle = (metadata?: Metadata) => setState(s => ({ ...s, status: 'idle', metadata })); + const setError = (metadata: ClerkRuntimeError | ClerkAPIError | Metadata | string) => + setState(s => ({ ...s, error: translateError(metadata) })); + const setLoading = (metadata?: Metadata) => setState(s => ({ ...s, status: 'loading', metadata })); + const runAsync = async (cb: Promise | (() => Promise), metadata?: Metadata) => { + setLoading(metadata); + return (typeof cb === 'function' ? cb() : cb) + .then(res => { + return res; + }) + .finally(() => setIdle(metadata)); + }; + + return { + setIdle, + setError, + setLoading, + runAsync, + loadingMetadata: state.status === 'loading' ? state.metadata : undefined, + error: state.error ? state.error : undefined, + isLoading: state.status === 'loading', + isIdle: state.status === 'idle', + }; +}; + +export { useCardState, CardStateProvider }; + +export const withCardStateProvider = (Component: React.ComponentType) => { + return (props: T) => { + return ( + + {/* @ts-expect-error */} + + + ); + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/contexts/FloatingTreeContext.tsx b/packages/clerk-js/src/ui-retheme/elements/contexts/FloatingTreeContext.tsx new file mode 100644 index 0000000000..a554f8c666 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/contexts/FloatingTreeContext.tsx @@ -0,0 +1,19 @@ +import { FloatingTree, useFloatingParentNodeId } from '@floating-ui/react'; +import React from 'react'; + +export const withFloatingTree = (Component: React.ComponentType): React.ComponentType => { + return (props: T) => { + const parentId = useFloatingParentNodeId(); + if (parentId == null) { + return ( + + {/* @ts-expect-error */} + + + ); + } + + /* @ts-expect-error */ + return ; + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/elements/contexts/FlowMetadataContext.tsx b/packages/clerk-js/src/ui-retheme/elements/contexts/FlowMetadataContext.tsx new file mode 100644 index 0000000000..35bcd088af --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/contexts/FlowMetadataContext.tsx @@ -0,0 +1,44 @@ +import { createContextAndHook } from '@clerk/shared/react'; +import React from 'react'; + +type FlowMetadata = { + flow: + | 'signIn' + | 'signUp' + | 'userButton' + | 'userProfile' + | 'organizationProfile' + | 'createOrganization' + | 'organizationSwitcher' + | 'organizationList'; + part?: + | 'start' + | 'emailCode' + | 'phoneCode' + | 'phoneCode2Fa' + | 'totp2Fa' + | 'backupCode2Fa' + | 'password' + | 'resetPassword' + | 'emailLink' + | 'emailLinkVerify' + | 'emailLinkStatus' + | 'alternativeMethods' + | 'forgotPasswordMethods' + | 'havingTrouble' + | 'ssoCallback' + | 'popover' + | 'complete' + | 'accountSwitcher'; +}; + +const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('FlowMetadata'); + +const FlowMetadataProvider = (props: React.PropsWithChildren) => { + const { flow, part } = props; + const value = React.useMemo(() => ({ value: props }), [flow, part]); + return {props.children}; +}; + +export { useFlowMetadata, FlowMetadataProvider }; +export type { FlowMetadata }; diff --git a/packages/clerk-js/src/ui-retheme/elements/contexts/index.ts b/packages/clerk-js/src/ui-retheme/elements/contexts/index.ts new file mode 100644 index 0000000000..97db4e0373 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/contexts/index.ts @@ -0,0 +1,3 @@ +export * from './CardStateContext'; +export * from './FlowMetadataContext'; +export * from './FloatingTreeContext'; diff --git a/packages/clerk-js/src/ui-retheme/elements/index.ts b/packages/clerk-js/src/ui-retheme/elements/index.ts new file mode 100644 index 0000000000..bf16d8e4a0 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/index.ts @@ -0,0 +1,59 @@ +export * from './contexts'; +export * from './Header'; +export * from './Footer'; +export * from './Alert'; +export * from './Form'; +export * from './BlockWithTrailingComponent'; +export * from './BackLink'; +export * from './IdentityPreview'; +export * from './Avatar'; +export * from './CodeControl'; +export * from './TimerButton'; +export * from './VerificationCodeCard'; +export * from './LoadingCard'; +export * from './ErrorCard'; +export * from './VerificationLinkCard'; +export * from './PoweredByClerk'; +export * from './ApplicationLogo'; +export * from './Card'; +export * from './ArrowBlockButton'; +export * from './ReversibleContainer'; +export * from './Divider'; +export * from './Modal'; +export * from './UserPreview'; +export * from './Accordion'; +export * from './FormattedPhoneNumber'; +export * from './FileDropArea'; +export * from './RootBox'; +export * from './InvisibleRootBox'; +export * from './ClipboardInput'; +export * from './TileButton'; +export * from './Select'; +export * from './Menu'; +export * from './Pagination'; +export * from './FullHeightLoader'; +export * from './Tabs'; +export * from './InputWithIcon'; +export * from './UserAvatar'; +export * from './OrganizationAvatar'; +export * from './OrganizationPreview'; +export * from './PersonalWorkspacePreview'; +export * from './Navbar'; +export * from './Breadcrumbs'; +export * from './ContentPage'; +export * from './ProfileCardContent'; +export * from './IconButton'; +export * from './AvatarUploader'; +export * from './Actions'; +export * from './PopoverCard'; +export * from './TagInput'; +export * from './ThreeDotsMenu'; +export * from './FormButtons'; +export * from './NavigateToFlowStartButton'; +export * from './SuccessPage'; +export * from './IconCircle'; +export * from './Popover'; +export * from './Section'; +export * from './PreviewButton'; +export * from './InformationBox'; +export * from './withAvatarShimmer'; diff --git a/packages/clerk-js/src/ui-retheme/elements/withAvatarShimmer.tsx b/packages/clerk-js/src/ui-retheme/elements/withAvatarShimmer.tsx new file mode 100644 index 0000000000..33816c1abc --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/elements/withAvatarShimmer.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef } from 'react'; + +import { useAppearance } from '../customizables'; +import type { ThemableCssProp } from '../styledSystem'; + +/** + * This HOC is used to add the hover selector for the avatar shimmer effect to its immediate child. + * It is used since we might want to add the selector to a different element than the avatar itself, + * for example in the + */ +export const withAvatarShimmer = (Component: React.ComponentType) => { + return forwardRef((props, ref) => { + const { parsedLayout } = useAppearance(); + + return ( + ({ + ':hover': { + '--cl-shimmer-hover-shadow': t.shadows.$shadowShimmer, + '--cl-shimmer-hover-transform': 'skew(-45deg) translateX(600%)', + '--cl-shimmer-hover-after-transform': 'skewX(45deg) translateX(-150%)', + }, + }) + : {}, + props.sx, + ]} + /> + ); + }); +}; diff --git a/packages/clerk-js/src/ui-retheme/foundations/__tests__/createInternalTheme.test.ts b/packages/clerk-js/src/ui-retheme/foundations/__tests__/createInternalTheme.test.ts new file mode 100644 index 0000000000..34a8c52b32 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/__tests__/createInternalTheme.test.ts @@ -0,0 +1,37 @@ +import { createInternalTheme } from '../createInternalTheme'; + +describe('createInternalTheme', () => { + it('handles empty objects', () => { + const foundations = {}; + const res = createInternalTheme(foundations as any); + expect(res).toEqual({}); + }); + + it('handles empty objects', () => { + const foundations = { + colors: { + primary500: 'primary500', + primary50: 'primary50', + }, + radii: { + 1: '1', + 2: '2', + }, + sizes: {}, + spaces: undefined, + }; + const res = createInternalTheme(foundations as any); + expect(res).toEqual({ + colors: { + $primary500: 'primary500', + $primary50: 'primary50', + }, + radii: { + $1: '1', + $2: '2', + }, + sizes: {}, + spaces: {}, + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/foundations/borders.ts b/packages/clerk-js/src/ui-retheme/foundations/borders.ts new file mode 100644 index 0000000000..58854a02f7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/borders.ts @@ -0,0 +1,4 @@ +export const borders = Object.freeze({ + normal: '1px solid', + heavy: '2px solid', +} as const); diff --git a/packages/clerk-js/src/ui-retheme/foundations/colors.ts b/packages/clerk-js/src/ui-retheme/foundations/colors.ts new file mode 100644 index 0000000000..6246c1bfa1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/colors.ts @@ -0,0 +1,88 @@ +export const whiteAlpha = Object.freeze({ + whiteAlpha20: 'hsla(0, 0%, 100%, 0.02)', + whiteAlpha50: 'hsla(0, 0%, 100%, 0.04)', + whiteAlpha100: 'hsla(0, 0%, 100%, 0.06)', + whiteAlpha200: 'hsla(0, 0%, 100%, 0.08)', + whiteAlpha300: 'hsla(0, 0%, 100%, 0.16)', + whiteAlpha400: 'hsla(0, 0%, 100%, 0.24)', + whiteAlpha500: 'hsla(0, 0%, 100%, 0.36)', + whiteAlpha600: 'hsla(0, 0%, 100%, 0.48)', + whiteAlpha700: 'hsla(0, 0%, 100%, 0.64)', + whiteAlpha800: 'hsla(0, 0%, 100%, 0.80)', + whiteAlpha900: 'hsla(0, 0%, 100%, 0.92)', +} as const); + +export const blackAlpha = Object.freeze({ + blackAlpha20: 'hsla(0, 0%, 0%, 0.02)', + blackAlpha50: 'hsla(0, 0%, 0%, 0.04)', + blackAlpha100: 'hsla(0, 0%, 0%, 0.06)', + blackAlpha200: 'hsla(0, 0%, 0%, 0.08)', + blackAlpha300: 'hsla(0, 0%, 0%, 0.16)', + blackAlpha400: 'hsla(0, 0%, 0%, 0.24)', + blackAlpha500: 'hsla(0, 0%, 0%, 0.36)', + blackAlpha600: 'hsla(0, 0%, 0%, 0.48)', + blackAlpha700: 'hsla(0, 0%, 0%, 0.64)', + blackAlpha800: 'hsla(0, 0%, 0%, 0.80)', + blackAlpha900: 'hsla(0, 0%, 0%, 0.92)', +} as const); + +export const colors = Object.freeze({ + // Colors that are not affected by `alphaShadesMode` + avatarBorder: blackAlpha.blackAlpha200, + avatarBackground: blackAlpha.blackAlpha400, + modalBackdrop: blackAlpha.blackAlpha700, + activeDeviceBackground: whiteAlpha.whiteAlpha200, + // Themable colors + ...blackAlpha, + ...whiteAlpha, + colorBackground: 'white', + colorInputBackground: 'white', + colorText: 'black', + colorTextOnPrimaryBackground: 'white', + colorTextSecondary: 'rgba(0,0,0,0.65)', + colorInputText: 'black', + colorShimmer: 'rgba(255, 255, 255, 0.36)', + transparent: 'transparent', + white: 'white', + black: 'black', + primary50: '#f0f3ff', + primary100: '#d1dcff', + primary200: '#91A7F7', + primary300: '#6684F5', + primary400: '#3B62F2', + primary500: '#103FEF', + primary600: '#0D33BF', + primary700: '#0A268F', + primary800: '#07195F', + primary900: '#030D30', + danger50: '#FEF3F2', + danger100: '#FEE4E2', + danger200: '#FECDCA', + danger300: '#FDA29B', + danger400: '#F97066', + danger500: '#F04438', + danger600: '#D92D20', + danger700: '#B42318', + danger800: '#912018', + danger900: '#7A271A', + warning50: '#FFFAEB', + warning100: '#FEF0C7', + warning200: '#FEDF89', + warning300: '#FEC84B', + warning400: '#FDB022', + warning500: '#F79009', + warning600: '#DC6803', + warning700: '#B54708', + warning800: '#93370D', + warning900: '#7A2E0E', + success50: '#ECFDF3', + success100: '#D1FADF', + success200: '#A6F4C5', + success300: '#6CE9A6', + success400: '#32D583', + success500: '#12B76A', + success600: '#039855', + success700: '#027A48', + success800: '#05603A', + success900: '#054F31', +} as const); diff --git a/packages/clerk-js/src/ui-retheme/foundations/createInternalTheme.ts b/packages/clerk-js/src/ui-retheme/foundations/createInternalTheme.ts new file mode 100644 index 0000000000..241ee1129e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/createInternalTheme.ts @@ -0,0 +1,13 @@ +import type { InternalTheme, InternalThemeFoundations } from './defaultFoundations'; + +export const createInternalTheme = (foundations: InternalThemeFoundations): InternalTheme => { + const res = {} as any; + const base = foundations as any; + for (const scale in base) { + res[scale] = {}; + for (const shade in base[scale]) { + res[scale]['$' + shade] = base[scale][shade]; + } + } + return Object.freeze(res); +}; diff --git a/packages/clerk-js/src/ui-retheme/foundations/defaultFoundations.ts b/packages/clerk-js/src/ui-retheme/foundations/defaultFoundations.ts new file mode 100644 index 0000000000..53bdfc24a2 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/defaultFoundations.ts @@ -0,0 +1,45 @@ +import { borders } from './borders'; +import { colors } from './colors'; +import { opacity } from './opacity'; +import { shadows } from './shadows'; +import { radii, sizes, space } from './sizes'; +import { transitionDuration, transitionProperty, transitionTiming } from './transitions'; +import { fonts, fontSizes, fontStyles, fontWeights, letterSpacings, lineHeights } from './typography'; +import { zIndices } from './zIndices'; + +const options = { + fontSmoothing: 'auto !important', +} as const; + +const defaultInternalThemeFoundations = Object.freeze({ + colors, + fonts, + fontStyles, + fontSizes, + fontWeights, + letterSpacings, + lineHeights, + radii, + sizes, + space, + shadows, + transitionProperty, + transitionTiming, + transitionDuration, + opacity, + borders, + zIndices, + options, +} as const); + +type InternalThemeFoundations = typeof defaultInternalThemeFoundations; + +type PrefixWith = `${K}${Extract}`; +type InternalTheme = { + [scale in keyof F]: { + [token in keyof F[scale] as PrefixWith<'$', token>]: F[scale][token]; + }; +}; + +export { defaultInternalThemeFoundations }; +export type { InternalTheme, InternalThemeFoundations }; diff --git a/packages/clerk-js/src/ui-retheme/foundations/index.ts b/packages/clerk-js/src/ui-retheme/foundations/index.ts new file mode 100644 index 0000000000..935b65330c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/index.ts @@ -0,0 +1,9 @@ +import { createInternalTheme } from './createInternalTheme'; +import type { InternalTheme, InternalThemeFoundations } from './defaultFoundations'; +import { defaultInternalThemeFoundations } from './defaultFoundations'; + +const defaultInternalTheme = createInternalTheme(defaultInternalThemeFoundations); + +export { blackAlpha, whiteAlpha } from './colors'; +export { defaultInternalThemeFoundations, defaultInternalTheme, createInternalTheme }; +export type { InternalTheme, InternalThemeFoundations }; diff --git a/packages/clerk-js/src/ui-retheme/foundations/opacity.ts b/packages/clerk-js/src/ui-retheme/foundations/opacity.ts new file mode 100644 index 0000000000..57597c7205 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/opacity.ts @@ -0,0 +1,5 @@ +export const opacity = Object.freeze({ + sm: '24%', + disabled: '50%', + inactive: '62%', +} as const); diff --git a/packages/clerk-js/src/ui-retheme/foundations/shadows.ts b/packages/clerk-js/src/ui-retheme/foundations/shadows.ts new file mode 100644 index 0000000000..21245110eb --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/shadows.ts @@ -0,0 +1,9 @@ +export const shadows = Object.freeze({ + /* Dashboard/Shadow 2 */ + cardDropShadow: '0px 24px 48px rgba(0, 0, 0, 0.16)', + boxShadow1: '0px 24px 48px rgba(0, 0, 0, 0.16)', + fabShadow: '0px 12px 24px rgba(0, 0, 0, 0.32)', + focusRing: '0 0 0 3px {{color}}', + focusRingInput: '0 0 0 1px {{color}}', + shadowShimmer: '1px 1px 2px rgba(0, 0, 0, 0.36)', +} as const); diff --git a/packages/clerk-js/src/ui-retheme/foundations/sizes.ts b/packages/clerk-js/src/ui-retheme/foundations/sizes.ts new file mode 100644 index 0000000000..0bdaa9c2e8 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/sizes.ts @@ -0,0 +1,70 @@ +const baseSpaceUnits = Object.freeze({ + none: '0', + xxs: '0.5px', + px: '1px', +} as const); + +const dynamicSpaceUnits = Object.freeze({ + '0x5': '0.125rem', + '1': '0.25rem', + '1x5': '0.375rem', + '2': '0.5rem', + '2x5': '0.625rem', + '3': '0.75rem', + '3x5': '0.875rem', + '4': '1rem', + '5': '1.25rem', + '6': '1.5rem', + '7': '1.75rem', + '8': '2rem', + '9': '2.25rem', + '9x5': '2.375rem', + '10': '2.5rem', + '11': '2.75rem', + '12': '3rem', + '12x5': '3.125rem', + '14': '3.5rem', + '15': '3.75rem', + '16': '4rem', + '20': '5rem', + '24': '6rem', + '48': '12rem', + '60': '15rem', + '94': '23.5rem', + '100': '25rem', + '120': '30rem', + '140': '35rem', + '160': '40rem', + '176': '44rem', + '220': '55rem', +} as const); + +/** + * Instead of generating these values with the helpers of parseVariables, + * we hard code them in order to have better intellisense support while developing + */ +const space = Object.freeze({ + ...baseSpaceUnits, + ...dynamicSpaceUnits, +} as const); + +const sizes = Object.freeze({ ...space } as const); + +const radii = Object.freeze({ + none: '0px', + circle: '50%', + sm: '0.25rem', + md: '0.375rem', + lg: '0.5rem', + xl: '1rem', + '2xl': '1.25rem', + halfHeight: '99999px', +} as const); + +/** + * Used by the space scale generation helpers. + * These keys should always match {@link space} + */ +const spaceScaleKeys = Object.keys(dynamicSpaceUnits) as Array; + +export { sizes, space, radii, spaceScaleKeys }; diff --git a/packages/clerk-js/src/ui-retheme/foundations/transitions.ts b/packages/clerk-js/src/ui-retheme/foundations/transitions.ts new file mode 100644 index 0000000000..094bc7b334 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/transitions.ts @@ -0,0 +1,21 @@ +const transitionDuration = Object.freeze({ + slowest: '600ms', + slower: '280ms', + slow: '200ms', + fast: '120ms', + focusRing: '200ms', + controls: '100ms', + textField: '450ms', +} as const); + +const transitionProperty = Object.freeze({ + common: 'background-color,border-color,color,fill,stroke,opacity,box-shadow,transform', +} as const); + +const transitionTiming = Object.freeze({ + common: 'ease', + easeOut: 'ease-out', + slowBezier: 'cubic-bezier(0.16, 1, 0.3, 1)', +} as const); + +export { transitionDuration, transitionTiming, transitionProperty }; diff --git a/packages/clerk-js/src/ui-retheme/foundations/typography.ts b/packages/clerk-js/src/ui-retheme/foundations/typography.ts new file mode 100644 index 0000000000..44b90ffe3c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/typography.ts @@ -0,0 +1,46 @@ +const fontWeights = Object.freeze({ + normal: 400, + medium: 500, + bold: 600, +} as const); + +const lineHeights = Object.freeze({ + normal: 'normal', + none: 1, + shortest: 1.1, + shorter: 1.25, + short: 1.375, + base: 1.5, + tall: 1.625, + taller: 2, +} as const); + +const letterSpacings = Object.freeze({ + tighter: '-0.05em', + tight: '-0.025em', + normal: '0', + wide: '0.025em', + wider: '0.05em', + widest: '0.1em', +} as const); + +const fontSizes = Object.freeze({ + '2xs': '0.6875rem', + xs: '0.8125rem', + sm: '0.875rem', + md: '1rem', + lg: '1.125rem', + xl: '1.25rem', + '2xl': '2rem', +} as const); + +const fontStyles = Object.freeze({ + normal: 'normal', +} as const); + +const fonts = Object.freeze({ + main: 'inherit', + buttons: 'inherit', +} as const); + +export { fontSizes, fontWeights, letterSpacings, lineHeights, fonts, fontStyles }; diff --git a/packages/clerk-js/src/ui-retheme/foundations/zIndices.ts b/packages/clerk-js/src/ui-retheme/foundations/zIndices.ts new file mode 100644 index 0000000000..f4f81f5a69 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/foundations/zIndices.ts @@ -0,0 +1,6 @@ +export const zIndices = Object.freeze({ + navbar: '100', + fab: '9000', + modal: '10000', + dropdown: '11000', +} as const); diff --git a/packages/clerk-js/src/ui-retheme/hooks/__tests__/useCoreOrganization.test.tsx b/packages/clerk-js/src/ui-retheme/hooks/__tests__/useCoreOrganization.test.tsx new file mode 100644 index 0000000000..1d6403d810 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/__tests__/useCoreOrganization.test.tsx @@ -0,0 +1,430 @@ +import { describe } from '@jest/globals'; + +import { act, bindCreateFixtures, renderHook, waitFor } from '../../../testUtils'; +import { + createFakeDomain, + createFakeOrganizationMembershipRequest, +} from '../../components/OrganizationProfile/__tests__/utils'; +import { createFakeUserOrganizationMembership } from '../../components/OrganizationSwitcher/__tests__/utlis'; +import { useCoreOrganization } from '../../contexts'; + +const { createFixtures } = bindCreateFixtures('OrganizationProfile'); + +const defaultRenderer = () => + useCoreOrganization({ + domains: { + pageSize: 2, + }, + membershipRequests: { + pageSize: 2, + }, + memberships: { + pageSize: 2, + }, + }); + +const undefinedPaginatedResource = { + data: [], + count: 0, + isLoading: false, + isFetching: false, + isError: false, + page: 1, + pageCount: 0, + hasNextPage: false, + hasPreviousPage: false, +}; + +describe('useOrganization', () => { + it('returns default values', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + const { result } = renderHook(useCoreOrganization, { wrapper }); + + expect(result.current.isLoaded).toBe(true); + expect(result.current.organization).toBeDefined(); + expect(result.current.organization).not.toBeNull(); + expect(result.current.organization).toEqual( + expect.objectContaining({ + name: 'Org1', + id: 'Org1', + }), + ); + + expect(result.current.invitationList).not.toBeDefined(); + expect(result.current.membershipList).not.toBeDefined(); + + expect(result.current.memberships).toEqual(expect.objectContaining(undefinedPaginatedResource)); + expect(result.current.domains).toEqual(expect.objectContaining(undefinedPaginatedResource)); + expect(result.current.membershipRequests).toEqual(expect.objectContaining(undefinedPaginatedResource)); + }); + + it('returns null when a organization is not active ', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + }); + }); + + const { result } = renderHook(useCoreOrganization, { wrapper }); + + expect(result.current.isLoaded).toBe(true); + expect(result.current.organization).toBeNull(); + + expect(result.current.invitationList).toBeNull(); + expect(result.current.membershipList).toBeNull(); + + expect(result.current.memberships).toBeNull(); + expect(result.current.domains).toBeNull(); + expect(result.current.membershipRequests).toBeNull(); + }); + + describe('memberships', () => { + it('fetch with pages', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.organization?.getMemberships.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + createFakeUserOrganizationMembership({ + id: '2', + organization: { + id: '2', + name: 'Org2', + slug: 'org2', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook(defaultRenderer, { wrapper }); + + expect(result.current.memberships).not.toBeNull(); + expect(result.current.memberships?.isLoading).toBe(true); + expect(result.current.memberships?.isFetching).toBe(true); + expect(result.current.memberships?.count).toBe(0); + + await waitFor(() => { + expect(result.current.memberships?.isLoading).toBe(false); + expect(result.current.memberships?.count).toBe(4); + expect(result.current.memberships?.page).toBe(1); + expect(result.current.memberships?.pageCount).toBe(2); + expect(result.current.memberships?.hasNextPage).toBe(true); + expect(result.current.memberships?.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '1', + }), + expect.objectContaining({ + id: '2', + }), + ]), + ); + }); + + fixtures.clerk.organization?.getMemberships.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '3', + organization: { + id: '3', + name: 'Org3', + slug: 'org3', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + createFakeUserOrganizationMembership({ + id: '4', + organization: { + id: '4', + name: 'Org4', + slug: 'org4', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.memberships?.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.memberships?.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.memberships?.isLoading).toBe(false); + expect(result.current.memberships?.page).toBe(2); + expect(result.current.memberships?.hasNextPage).toBe(false); + expect(result.current.memberships?.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: '1', + }), + expect.not.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + }); + + describe('domains', () => { + it('fetch with pages', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [ + createFakeDomain({ + id: '1', + name: 'one.dev', + organizationId: '1', + }), + createFakeDomain({ + id: '2', + name: 'two.dev', + organizationId: '2', + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook(defaultRenderer, { wrapper }); + expect(result.current.domains?.isLoading).toBe(true); + expect(result.current.domains?.isFetching).toBe(true); + expect(result.current.domains?.count).toBe(0); + + await waitFor(() => { + expect(result.current.domains?.isLoading).toBe(false); + expect(result.current.domains?.count).toBe(4); + expect(result.current.domains?.page).toBe(1); + expect(result.current.domains?.pageCount).toBe(2); + expect(result.current.domains?.hasNextPage).toBe(true); + expect(result.current.domains?.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '1', + name: 'one.dev', + }), + expect.objectContaining({ + id: '2', + name: 'two.dev', + }), + ]), + ); + }); + + fixtures.clerk.organization?.getDomains.mockReturnValue( + Promise.resolve({ + data: [ + createFakeDomain({ + id: '3', + name: 'three.dev', + organizationId: '3', + }), + createFakeDomain({ + id: '4', + name: 'four.dev', + organizationId: '4', + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.domains?.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.domains?.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.domains?.isLoading).toBe(false); + expect(result.current.domains?.page).toBe(2); + expect(result.current.domains?.hasNextPage).toBe(false); + expect(result.current.domains?.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: '1', + }), + expect.not.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + }); + + describe('membershipRequests', () => { + it('fetch with pages', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.organization?.getMembershipRequests.mockReturnValue( + Promise.resolve({ + data: [ + createFakeOrganizationMembershipRequest({ + id: '1', + organizationId: '1', + publicUserData: { + userId: 'test_user1', + identifier: 'test1@clerk.com', + }, + }), + createFakeOrganizationMembershipRequest({ + id: '2', + organizationId: '1', + publicUserData: { + userId: 'test_user2', + identifier: 'test2@clerk.com', + }, + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook(defaultRenderer, { wrapper }); + expect(result.current.membershipRequests?.isLoading).toBe(true); + expect(result.current.membershipRequests?.isFetching).toBe(true); + expect(result.current.membershipRequests?.count).toBe(0); + + await waitFor(() => { + expect(result.current.membershipRequests?.isLoading).toBe(false); + expect(result.current.membershipRequests?.count).toBe(4); + expect(result.current.membershipRequests?.page).toBe(1); + expect(result.current.membershipRequests?.pageCount).toBe(2); + expect(result.current.membershipRequests?.hasNextPage).toBe(true); + }); + + fixtures.clerk.organization?.getMembershipRequests.mockReturnValue( + Promise.resolve({ + data: [ + createFakeOrganizationMembershipRequest({ + id: '3', + organizationId: '1', + publicUserData: { + userId: 'test_user3', + identifier: 'test3@clerk.com', + }, + }), + createFakeOrganizationMembershipRequest({ + id: '4', + organizationId: '1', + publicUserData: { + userId: 'test_user4', + identifier: 'test4@clerk.com', + }, + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.membershipRequests?.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.membershipRequests?.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.membershipRequests?.isLoading).toBe(false); + expect(result.current.membershipRequests?.page).toBe(2); + expect(result.current.membershipRequests?.hasNextPage).toBe(false); + expect(result.current.membershipRequests?.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: '1', + }), + expect.not.objectContaining({ + id: '2', + }), + expect.objectContaining({ + organizationId: '1', + id: '3', + publicUserData: expect.objectContaining({ + userId: 'test_user3', + }), + }), + expect.objectContaining({ + organizationId: '1', + id: '4', + publicUserData: expect.objectContaining({ + userId: 'test_user4', + }), + }), + ]), + ); + }); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/hooks/__tests__/useCoreOrganizationList.test.tsx b/packages/clerk-js/src/ui-retheme/hooks/__tests__/useCoreOrganizationList.test.tsx new file mode 100644 index 0000000000..381fb568e6 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/__tests__/useCoreOrganizationList.test.tsx @@ -0,0 +1,715 @@ +import { describe } from '@jest/globals'; + +import { act, bindCreateFixtures, renderHook, waitFor } from '../../../testUtils'; +import { + createFakeUserOrganizationInvitation, + createFakeUserOrganizationMembership, + createFakeUserOrganizationSuggestion, +} from '../../components/OrganizationSwitcher/__tests__/utlis'; +import { useCoreOrganizationList } from '../../contexts'; + +const { createFixtures } = bindCreateFixtures('OrganizationSwitcher'); + +const defaultRenderer = () => + useCoreOrganizationList({ + userMemberships: { + pageSize: 2, + }, + userInvitations: { + pageSize: 2, + }, + userSuggestions: { + pageSize: 2, + }, + }); + +describe('useOrganizationList', () => { + it('opens organization profile when "Manage Organization" is clicked', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + const { result } = renderHook(useCoreOrganizationList, { wrapper }); + + expect(result.current.isLoaded).toBe(true); + expect(result.current.setActive).toBeDefined(); + expect(result.current.createOrganization).toBeDefined(); + expect(result.current.organizationList).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + membership: expect.objectContaining({ + role: 'basic_member', + }), + }), + ]), + ); + + expect(result.current.userInvitations).toEqual( + expect.objectContaining({ + data: [], + count: 0, + isLoading: false, + isFetching: false, + isError: false, + page: 1, + pageCount: 0, + hasNextPage: false, + hasPreviousPage: false, + }), + ); + }); + + describe('userMemberships', () => { + it('fetch with pages', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + createFakeUserOrganizationMembership({ + id: '2', + organization: { + id: '2', + name: 'Org2', + slug: 'org2', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook(defaultRenderer, { wrapper }); + expect(result.current.userMemberships.isLoading).toBe(true); + expect(result.current.userMemberships.isFetching).toBe(true); + expect(result.current.userMemberships.count).toBe(0); + + await waitFor(() => { + expect(result.current.userMemberships.isLoading).toBe(false); + expect(result.current.userMemberships.count).toBe(4); + expect(result.current.userMemberships.page).toBe(1); + expect(result.current.userMemberships.pageCount).toBe(2); + expect(result.current.userMemberships.hasNextPage).toBe(true); + }); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '3', + organization: { + id: '3', + name: 'Org3', + slug: 'org3', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + createFakeUserOrganizationMembership({ + id: '4', + organization: { + id: '4', + name: 'Org4', + slug: 'org4', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userMemberships.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userMemberships.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userMemberships.isLoading).toBe(false); + expect(result.current.userMemberships.page).toBe(2); + expect(result.current.userMemberships.hasNextPage).toBe(false); + expect(result.current.userMemberships.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: '1', + }), + expect.not.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + + it('infinite fetch', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + createFakeUserOrganizationMembership({ + id: '2', + organization: { + id: '2', + name: 'Org2', + slug: 'org2', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook( + () => + useCoreOrganizationList({ + userMemberships: { + pageSize: 2, + infinite: true, + }, + }), + { wrapper }, + ); + expect(result.current.userMemberships.isLoading).toBe(true); + expect(result.current.userMemberships.isFetching).toBe(true); + + await waitFor(() => { + expect(result.current.userMemberships.isLoading).toBe(false); + expect(result.current.userMemberships.isFetching).toBe(false); + }); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '1', + organization: { + id: '1', + name: 'Org1', + slug: 'org1', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + createFakeUserOrganizationMembership({ + id: '2', + organization: { + id: '2', + name: 'Org2', + slug: 'org2', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 4, + }), + ); + + fixtures.clerk.user?.getOrganizationMemberships.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationMembership({ + id: '3', + organization: { + id: '3', + name: 'Org3', + slug: 'org3', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + createFakeUserOrganizationMembership({ + id: '4', + organization: { + id: '4', + name: 'Org4', + slug: 'org4', + membersCount: 1, + adminDeleteEnabled: false, + maxAllowedMemberships: 0, + pendingInvitationsCount: 1, + }, + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userMemberships.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userMemberships.isLoading).toBe(false); + expect(result.current.userMemberships.isFetching).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userMemberships.isFetching).toBe(false); + expect(result.current.userMemberships.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '1', + }), + expect.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + }); + + describe('userInvitations', () => { + it('fetch with pages', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitation({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationInvitation({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook(defaultRenderer, { wrapper }); + expect(result.current.userInvitations.isLoading).toBe(true); + expect(result.current.userInvitations.isFetching).toBe(true); + expect(result.current.userInvitations.count).toBe(0); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.count).toBe(4); + expect(result.current.userInvitations.page).toBe(1); + expect(result.current.userInvitations.pageCount).toBe(2); + expect(result.current.userInvitations.hasNextPage).toBe(true); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitation({ + id: '3', + emailAddress: 'three@clerk.com', + }), + createFakeUserOrganizationInvitation({ + id: '4', + emailAddress: 'four@clerk.com', + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userInvitations.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.page).toBe(2); + expect(result.current.userInvitations.hasNextPage).toBe(false); + expect(result.current.userInvitations.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: '1', + }), + expect.not.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + + it('infinite fetch', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitation({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationInvitation({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook( + () => + useCoreOrganizationList({ + userInvitations: { + pageSize: 2, + infinite: true, + }, + }), + { wrapper }, + ); + expect(result.current.userInvitations.isLoading).toBe(true); + expect(result.current.userInvitations.isFetching).toBe(true); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.isFetching).toBe(false); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitation({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationInvitation({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitation({ + id: '3', + emailAddress: 'three@clerk.com', + }), + createFakeUserOrganizationInvitation({ + id: '4', + emailAddress: 'four@clerk.com', + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userInvitations.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.isFetching).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isFetching).toBe(false); + expect(result.current.userInvitations.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '1', + }), + expect.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + }); + + describe('userSuggestions', () => { + it('fetch with pages', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationSuggestion({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationSuggestion({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook(defaultRenderer, { wrapper }); + expect(result.current.userSuggestions.isLoading).toBe(true); + expect(result.current.userSuggestions.isFetching).toBe(true); + expect(result.current.userSuggestions.count).toBe(0); + + await waitFor(() => { + expect(result.current.userSuggestions.isLoading).toBe(false); + expect(result.current.userSuggestions.count).toBe(4); + expect(result.current.userSuggestions.page).toBe(1); + expect(result.current.userSuggestions.pageCount).toBe(2); + expect(result.current.userSuggestions.hasNextPage).toBe(true); + }); + + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationSuggestion({ + id: '3', + emailAddress: 'three@clerk.com', + }), + createFakeUserOrganizationSuggestion({ + id: '4', + emailAddress: 'four@clerk.com', + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userSuggestions.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userSuggestions.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userSuggestions.isLoading).toBe(false); + expect(result.current.userSuggestions.page).toBe(2); + expect(result.current.userSuggestions.hasNextPage).toBe(false); + expect(result.current.userSuggestions.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: '1', + }), + expect.not.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + + it('infinite fetch', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.com'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationSuggestion({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationSuggestion({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook( + () => + useCoreOrganizationList({ + userSuggestions: { + pageSize: 2, + infinite: true, + }, + }), + { wrapper }, + ); + expect(result.current.userSuggestions.isLoading).toBe(true); + expect(result.current.userSuggestions.isFetching).toBe(true); + + await waitFor(() => { + expect(result.current.userSuggestions.isLoading).toBe(false); + expect(result.current.userSuggestions.isFetching).toBe(false); + }); + + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationSuggestion({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationSuggestion({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + + fixtures.clerk.user?.getOrganizationSuggestions.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationSuggestion({ + id: '3', + emailAddress: 'three@clerk.com', + }), + createFakeUserOrganizationSuggestion({ + id: '4', + emailAddress: 'four@clerk.com', + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userSuggestions.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userSuggestions.isLoading).toBe(false); + expect(result.current.userSuggestions.isFetching).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userSuggestions.isFetching).toBe(false); + expect(result.current.userSuggestions.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '1', + }), + expect.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/hooks/__tests__/usePasswordComplexity.test.tsx b/packages/clerk-js/src/ui-retheme/hooks/__tests__/usePasswordComplexity.test.tsx new file mode 100644 index 0000000000..3b1e273b30 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/__tests__/usePasswordComplexity.test.tsx @@ -0,0 +1,167 @@ +import { act, bindCreateFixtures, renderHook } from '../../../testUtils'; +import { usePasswordComplexity } from '../usePasswordComplexity'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const defaultRenderer = () => + usePasswordComplexity({ + allowed_special_characters: '', + max_length: 999, + min_length: 8, + require_special_char: true, + require_numbers: true, + require_lowercase: true, + require_uppercase: true, + }); + +describe('usePasswordComplexity', () => { + it('internal passwords updates after calling setPassword', async () => { + const { wrapper } = await createFixtures(); + const { result } = renderHook(defaultRenderer, { wrapper }); + + await act(() => { + result.current.getComplexity('password1'); + }); + + expect(result.current.password).toBe('password1'); + }); + + it('password fails and hasFailedComplexity is true', async () => { + const { wrapper } = await createFixtures(); + const { result } = renderHook(defaultRenderer, { wrapper }); + + await act(() => { + result.current.getComplexity('thispasswordfails'); + }); + + expect(result.current.hasFailedComplexity).toBe(true); + expect(result.current.hasPassedComplexity).toBe(false); + }); + + it('password passes and hasPassedComplexity is true', async () => { + const { wrapper } = await createFixtures(); + const { result } = renderHook(defaultRenderer, { wrapper }); + + await act(() => { + result.current.getComplexity('th1sp@sswordPasses'); + }); + + expect(result.current.hasFailedComplexity).toBe(false); + expect(result.current.hasPassedComplexity).toBe(true); + }); + + it('returns object with the missing requirements as properties', async () => { + const { wrapper } = await createFixtures(); + const { result } = renderHook(defaultRenderer, { wrapper }); + + await act(() => { + result.current.getComplexity('thispasswordfails'); + }); + + expect(result.current.failedValidations).toHaveProperty('require_uppercase'); + expect(result.current.failedValidations).toHaveProperty('require_numbers'); + expect(result.current.failedValidations).toHaveProperty('require_special_char'); + expect(result.current.failedValidations).not.toHaveProperty('require_lowercase'); + expect(result.current.failedValidations).not.toHaveProperty('max_length'); + expect(result.current.failedValidations).not.toHaveProperty('min_length'); + + await act(() => { + result.current.getComplexity(`thispasswordfails"`); + }); + + expect(result.current.failedValidations).not.toHaveProperty('require_special_char'); + }); + + it('uses allowed_special_character from environment', async () => { + const { wrapper, fixtures } = await createFixtures(f => + f.withPasswordComplexity({ + allowed_special_characters: '@', + max_length: 999, + min_length: 8, + require_special_char: true, + require_numbers: true, + require_lowercase: true, + require_uppercase: true, + }), + ); + const { result } = renderHook(() => usePasswordComplexity(fixtures.environment.userSettings.passwordSettings), { + wrapper, + }); + + await act(() => { + result.current.getComplexity('@'); + }); + + expect(result.current.failedValidations).not.toHaveProperty('require_special_char'); + }); + + it('uses allowed_special_character from environment with escaped characters', async () => { + const { wrapper, fixtures } = await createFixtures(f => + f.withPasswordComplexity({ + allowed_special_characters: '[!"#$%&\'()*+,-./:;<=>?@^_`{|}~]', + max_length: 999, + min_length: 8, + require_special_char: true, + require_numbers: true, + require_lowercase: true, + require_uppercase: true, + }), + ); + const { result } = renderHook(() => usePasswordComplexity(fixtures.environment.userSettings.passwordSettings), { + wrapper, + }); + + await act(() => { + result.current.getComplexity('['); + }); + + expect(result.current.failedValidations).not.toHaveProperty('require_special_char'); + + await act(() => { + result.current.getComplexity(']'); + }); + + expect(result.current.failedValidations).not.toHaveProperty('require_special_char'); + + await act(() => { + result.current.getComplexity('[test]'); + }); + + expect(result.current.failedValidations).not.toHaveProperty('require_special_char'); + + await act(() => { + result.current.getComplexity('test[]'); + }); + + expect(result.current.failedValidations).not.toHaveProperty('require_special_char'); + + await act(() => { + result.current.getComplexity('[!"#$%&\'()*+,-./:;<=>?@^_`{|}~]'); + }); + + expect(result.current.failedValidations).not.toHaveProperty('require_special_char'); + }); + + it('returns error message with localized conjunction', async () => { + const { wrapper } = await createFixtures(); + const { result } = renderHook(defaultRenderer, { wrapper }); + + await act(() => { + result.current.getComplexity('@apapapap'); + }); + + expect(result.current.failedValidationsText).toBe('Your password must contain a number and an uppercase letter.'); + + await act(() => { + result.current.getComplexity('aPaPaPaPaP'); + }); + + expect(result.current.failedValidationsText).toBe('Your password must contain a special character and a number.'); + + await act(() => { + result.current.getComplexity('aP'); + }); + + expect(result.current.failedValidationsText).toBe('Your password must contain 8 or more characters.'); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/hooks/__tests__/useSupportEmail.test.tsx b/packages/clerk-js/src/ui-retheme/hooks/__tests__/useSupportEmail.test.tsx new file mode 100644 index 0000000000..d7c4614d0f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/__tests__/useSupportEmail.test.tsx @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; + +import { useSupportEmail } from '../useSupportEmail'; + +const mockUseOptions = jest.fn(); +const mockUseEnvironment = jest.fn(); + +jest.mock('../../contexts', () => { + return { + useCoreClerk: () => { + return { + frontendApi: 'clerk.clerk.com', + }; + }, + useEnvironment: () => mockUseEnvironment(), + useOptions: () => mockUseOptions(), + }; +}); + +describe('useSupportEmail', () => { + test('should use custom email when provided from options', () => { + mockUseOptions.mockImplementationOnce(() => ({ supportEmail: 'test@email.com' })); + mockUseEnvironment.mockImplementationOnce(() => ({ displayConfig: { supportEmail: null } })); + const { result } = renderHook(() => useSupportEmail()); + + expect(result.current).toBe('test@email.com'); + }); + + test('should use custom email when provided from the environment', () => { + mockUseOptions.mockImplementationOnce(() => ({})); + mockUseEnvironment.mockImplementationOnce(() => ({ displayConfig: { supportEmail: 'test@email.com' } })); + const { result } = renderHook(() => useSupportEmail()); + + expect(result.current).toBe('test@email.com'); + }); + + test('should fallback to default when supportEmail is not provided in options or the environment', () => { + mockUseOptions.mockImplementationOnce(() => ({})); + mockUseEnvironment.mockImplementationOnce(() => ({ displayConfig: { supportEmail: null } })); + const { result } = renderHook(() => useSupportEmail()); + + expect(result.current).toBe('support@clerk.com'); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/hooks/index.ts b/packages/clerk-js/src/ui-retheme/hooks/index.ts new file mode 100644 index 0000000000..d0527e8808 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/index.ts @@ -0,0 +1,21 @@ +export * from './useDelayedVisibility'; +export * from './useSaml'; +export * from './useWindowEventListener'; +export * from './useEmailLink'; +export * from './useClipboard'; +export * from './useEnabledThirdPartyProviders'; +export * from './useFetch'; +export * from './useInView'; +export * from './useLoadingStatus'; +export * from './usePassword'; +export * from './usePasswordComplexity'; +export * from './usePopover'; +export * from './usePrefersReducedMotion'; +export * from './useLocalStorage'; +export * from './useSafeState'; +export * from './useSearchInput'; +export * from './useDebounce'; +export * from './useScrollLock'; +export * from './useDeepEqualMemo'; +export * from './useClerkModalStateParams'; +export * from './useNavigateToFlowStart'; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useAlternativeStrategies.ts b/packages/clerk-js/src/ui-retheme/hooks/useAlternativeStrategies.ts new file mode 100644 index 0000000000..32967b1f66 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useAlternativeStrategies.ts @@ -0,0 +1,29 @@ +import type { SignInFactor } from '@clerk/types'; + +import { factorHasLocalStrategy, isResetPasswordStrategy } from '../components/SignIn/utils'; +import { useCoreSignIn } from '../contexts'; +import { allStrategiesButtonsComparator } from '../utils'; +import { useEnabledThirdPartyProviders } from './useEnabledThirdPartyProviders'; + +export function useAlternativeStrategies({ filterOutFactor }: { filterOutFactor: SignInFactor | null | undefined }) { + const { supportedFirstFactors } = useCoreSignIn(); + + const { strategies: OAuthStrategies } = useEnabledThirdPartyProviders(); + + const firstFactors = supportedFirstFactors.filter( + f => f.strategy !== filterOutFactor?.strategy && !isResetPasswordStrategy(f.strategy), + ); + + const shouldAllowForAlternativeStrategies = firstFactors.length + OAuthStrategies.length > 0; + + const firstPartyFactors = supportedFirstFactors + .filter(f => !f.strategy.startsWith('oauth_') && !(f.strategy === filterOutFactor?.strategy)) + .filter(factor => factorHasLocalStrategy(factor)) + .sort(allStrategiesButtonsComparator); + + return { + hasAnyStrategy: shouldAllowForAlternativeStrategies, + hasFirstParty: !!firstPartyFactors, + firstPartyFactors, + }; +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useClerkModalStateParams.tsx b/packages/clerk-js/src/ui-retheme/hooks/useClerkModalStateParams.tsx new file mode 100644 index 0000000000..282559ea50 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useClerkModalStateParams.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { CLERK_MODAL_STATE } from '../../core/constants'; +import { readStateParam, removeClerkQueryParam } from '../../utils'; + +export const useClerkModalStateParams = () => { + const [state, setState] = React.useState({ startPath: '', path: '', componentName: '', socialProvider: '' }); + const decodedRedirectParams = readStateParam(); + + React.useLayoutEffect(() => { + if (decodedRedirectParams) { + setState(decodedRedirectParams); + } + }, []); + + const clearUrlStateParam = () => { + setState({ startPath: '', path: '', componentName: '', socialProvider: '' }); + }; + + const removeQueryParam = () => removeClerkQueryParam(CLERK_MODAL_STATE); + + return { + urlStateParam: { ...state, clearUrlStateParam }, + decodedRedirectParams, + clearUrlStateParam, + removeQueryParam, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useClipboard.ts b/packages/clerk-js/src/ui-retheme/hooks/useClipboard.ts new file mode 100644 index 0000000000..c0ed9fcd76 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useClipboard.ts @@ -0,0 +1,52 @@ +import copy from 'copy-to-clipboard'; +import { useCallback, useEffect, useState } from 'react'; + +export interface UseClipboardOptions { + /** + * timeout delay (in ms) to switch back to initial state once copied. + */ + timeout?: number; + /** + * Set the desired MIME type + */ + format?: string; +} + +/** + * React hook to copy content to clipboard + * + * @param text the text or value to copy + * @param {Number} [optionsOrTimeout=1500] optionsOrTimeout - delay (in ms) to switch back to initial state once copied. + * @param {Object} optionsOrTimeout + * @param {string} optionsOrTimeout.format - set the desired MIME type + * @param {number} optionsOrTimeout.timeout - delay (in ms) to switch back to initial state once copied. + */ +export function useClipboard(text: string, optionsOrTimeout: number | UseClipboardOptions = {}) { + const [hasCopied, setHasCopied] = useState(false); + + const { timeout = 1500, ...copyOptions } = + typeof optionsOrTimeout === 'number' ? { timeout: optionsOrTimeout } : optionsOrTimeout; + + const onCopy = useCallback(() => { + const didCopy = copy(text, copyOptions); + setHasCopied(didCopy); + }, [text, copyOptions]); + + useEffect(() => { + let timeoutId: number | null = null; + + if (hasCopied) { + timeoutId = window.setTimeout(() => { + setHasCopied(false); + }, timeout); + } + + return () => { + if (timeoutId) { + window.clearTimeout(timeoutId); + } + }; + }, [timeout, hasCopied]); + + return { value: text, onCopy, hasCopied }; +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useDebounce.ts b/packages/clerk-js/src/ui-retheme/hooks/useDebounce.ts new file mode 100644 index 0000000000..b9b28fd66c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useDebounce.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +export function useDebounce(value: T, delayInMs?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + const [timeoutState, setTimeoutState] = useState | undefined>(undefined); + + useEffect(() => { + const handleDebounce = () => { + if (timeoutState) { + clearTimeout(timeoutState); + setTimeoutState(undefined); + } + + setTimeoutState( + setTimeout(() => { + setDebouncedValue(value); + setTimeoutState(undefined); + }, delayInMs || 500), + ); + }; + + handleDebounce(); + return () => { + if (timeoutState) { + clearTimeout(timeoutState); + setTimeoutState(undefined); + } + }; + }, [JSON.stringify(value), delayInMs]); + + return debouncedValue; +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useDeepEqualMemo.ts b/packages/clerk-js/src/ui-retheme/hooks/useDeepEqualMemo.ts new file mode 100644 index 0000000000..d86a60666b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useDeepEqualMemo.ts @@ -0,0 +1,18 @@ +import { dequal as deepEqual } from 'dequal'; +import React from 'react'; + +type UseMemoFactory = () => T; +type UseMemoDependencyArray = Exclude[1], 'undefined'>; +type UseDeepEqualMemo = (factory: UseMemoFactory, dependencyArray: UseMemoDependencyArray) => T; + +const useDeepEqualMemoize = (value: T) => { + const ref = React.useRef(value); + if (!deepEqual(value, ref.current)) { + ref.current = value; + } + return React.useMemo(() => ref.current, [ref.current]); +}; + +export const useDeepEqualMemo: UseDeepEqualMemo = (factory, dependencyArray) => { + return React.useMemo(factory, useDeepEqualMemoize(dependencyArray)); +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useDelayedVisibility.ts b/packages/clerk-js/src/ui-retheme/hooks/useDelayedVisibility.ts new file mode 100644 index 0000000000..62713780bd --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useDelayedVisibility.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; + +/** + * Utility hook for delaying mounting of components for enter and exit animations. + * Delays to update the state when is switched from/to undefined. + * Immediate change for in-between changes + */ +export function useDelayedVisibility(valueToDelay: T, delayInMs: number) { + const [isVisible, setVisible] = useState(undefined); + + useEffect(() => { + let timeoutId: ReturnType; + + if (valueToDelay && !isVisible) { + // First time that valueToDelay has truthy value means we want to display it + timeoutId = setTimeout(() => setVisible(valueToDelay), delayInMs); + } else if (!valueToDelay && isVisible) { + // valueToDelay has already a truthy value and becomes falsy means we want to hide it + timeoutId = setTimeout(() => setVisible(undefined), delayInMs); + } else { + // it is already displayed, and we want immediate updates to that value + setVisible(valueToDelay); + } + return () => clearTimeout(timeoutId); + }, [valueToDelay, delayInMs, isVisible]); + + return isVisible; +} + +export function useFieldMessageVisibility(fieldMessage: T, delayInMs: number) { + return useDelayedVisibility(fieldMessage, delayInMs); +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useEmailLink.ts b/packages/clerk-js/src/ui-retheme/hooks/useEmailLink.ts new file mode 100644 index 0000000000..35dd8f8314 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useEmailLink.ts @@ -0,0 +1,34 @@ +import type { + CreateEmailLinkFlowReturn, + EmailAddressResource, + SignInResource, + SignInStartEmailLinkFlowParams, + SignUpResource, + StartEmailLinkFlowParams, +} from '@clerk/types'; +import React from 'react'; + +type EmailLinkable = SignUpResource | EmailAddressResource | SignInResource; +type UseEmailLinkSignInReturn = CreateEmailLinkFlowReturn; +type UseEmailLinkSignUpReturn = CreateEmailLinkFlowReturn; +type UseEmailLinkEmailAddressReturn = CreateEmailLinkFlowReturn; + +function useEmailLink(resource: SignInResource): UseEmailLinkSignInReturn; +function useEmailLink(resource: SignUpResource): UseEmailLinkSignUpReturn; +function useEmailLink(resource: EmailAddressResource): UseEmailLinkEmailAddressReturn; +function useEmailLink( + resource: EmailLinkable, +): UseEmailLinkSignInReturn | UseEmailLinkSignUpReturn | UseEmailLinkEmailAddressReturn { + const { startEmailLinkFlow, cancelEmailLinkFlow } = React.useMemo(() => resource.createEmailLinkFlow(), [resource]); + + React.useEffect(() => { + return cancelEmailLinkFlow; + }, []); + + return { + startEmailLinkFlow, + cancelEmailLinkFlow, + } as UseEmailLinkSignInReturn | UseEmailLinkSignUpReturn | UseEmailLinkEmailAddressReturn; +} + +export { useEmailLink }; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useEnabledThirdPartyProviders.tsx b/packages/clerk-js/src/ui-retheme/hooks/useEnabledThirdPartyProviders.tsx new file mode 100644 index 0000000000..1063ba7a23 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useEnabledThirdPartyProviders.tsx @@ -0,0 +1,53 @@ +import type { OAuthProvider, OAuthStrategy, Web3Provider, Web3Strategy } from '@clerk/types'; +// TODO: This import shouldn't be part of @clerk/types +import { OAUTH_PROVIDERS, WEB3_PROVIDERS } from '@clerk/types'; + +import { iconImageUrl } from '../common/constants'; +import { useEnvironment } from '../contexts/EnvironmentContext'; +import { fromEntries } from '../utils'; + +type ThirdPartyStrategyToDataMap = { + [k in Web3Strategy | OAuthStrategy]: { + id: Web3Provider | OAuthProvider; + iconUrl: string; + name: string; + }; +}; + +type ThirdPartyProviderToDataMap = { + [k in Web3Provider | OAuthProvider]: { + strategy: Web3Strategy | OAuthStrategy; + iconUrl: string; + name: string; + }; +}; + +const oauthStrategies = OAUTH_PROVIDERS.map(p => p.strategy); + +const providerToDisplayData: ThirdPartyProviderToDataMap = fromEntries( + [...OAUTH_PROVIDERS, ...WEB3_PROVIDERS].map(p => { + return [p.provider, { strategy: p.strategy, name: p.name, iconUrl: iconImageUrl(p.provider) }]; + }), +) as ThirdPartyProviderToDataMap; + +const strategyToDisplayData: ThirdPartyStrategyToDataMap = fromEntries( + [...OAUTH_PROVIDERS, ...WEB3_PROVIDERS].map(p => { + return [p.strategy, { id: p.provider, name: p.name, iconUrl: iconImageUrl(p.provider) }]; + }), +) as ThirdPartyStrategyToDataMap; + +export const useEnabledThirdPartyProviders = () => { + const { socialProviderStrategies, web3FirstFactors, authenticatableSocialStrategies } = useEnvironment().userSettings; + + // Filter out any OAuth strategies that are not yet known, they are not included in our types. + const knownSocialProviderStrategies = socialProviderStrategies.filter(s => oauthStrategies.includes(s)); + const knownAuthenticatableSocialStrategies = authenticatableSocialStrategies.filter(s => oauthStrategies.includes(s)); + + return { + strategies: [...knownSocialProviderStrategies, ...web3FirstFactors], + web3Strategies: [...web3FirstFactors], + authenticatableOauthStrategies: [...knownAuthenticatableSocialStrategies], + strategyToDisplayData: strategyToDisplayData, + providerToDisplayData: providerToDisplayData, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useFetch.ts b/packages/clerk-js/src/ui-retheme/hooks/useFetch.ts new file mode 100644 index 0000000000..87c81d4347 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useFetch.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; + +import { useLoadingStatus } from './useLoadingStatus'; +import { useSafeState } from './useSafeState'; + +export const useFetch = ( + fetcher: ((...args: any) => Promise) | undefined, + params: any, + callbacks?: { + onSuccess?: (data: T) => void; + }, +) => { + const [data, setData] = useSafeState(null); + const requestStatus = useLoadingStatus({ + status: 'loading', + }); + + const fetcherRef = useRef(fetcher); + + useEffect(() => { + if (!fetcherRef.current) { + return; + } + requestStatus.setLoading(); + fetcherRef + .current(params) + .then(result => { + requestStatus.setIdle(); + if (typeof result !== 'undefined') { + setData(typeof result === 'object' ? { ...result } : result); + callbacks?.onSuccess?.(typeof result === 'object' ? { ...result } : result); + } + }) + .catch(() => { + requestStatus.setError(); + setData(null); + }); + }, [JSON.stringify(params)]); + + return { + status: requestStatus, + data, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useFetchRoles.ts b/packages/clerk-js/src/ui-retheme/hooks/useFetchRoles.ts new file mode 100644 index 0000000000..cbe0f32499 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useFetchRoles.ts @@ -0,0 +1,29 @@ +import { useCoreOrganization } from '../contexts'; +import { useLocalizations } from '../localization'; +import { customRoleLocalizationKey, roleLocalizationKey } from '../utils'; +import { useFetch } from './useFetch'; + +const getRolesParams = { + /** + * Fetch at most 20 roles, it is not expected for an app to have more. + * We also prevent the creation of more than 20 roles in dashboard. + */ + pageSize: 20, +}; +export const useFetchRoles = () => { + const { organization } = useCoreOrganization(); + const { data, status } = useFetch(organization?.getRoles, getRolesParams); + + return { + isLoading: status.isLoading, + options: data?.data?.map(role => ({ value: role.key, label: role.name })), + }; +}; + +export const useLocalizeCustomRoles = () => { + const { t } = useLocalizations(); + return { + localizeCustomRole: (param: string | undefined) => + t(customRoleLocalizationKey(param)) || t(roleLocalizationKey(param)), + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useInView.ts b/packages/clerk-js/src/ui-retheme/hooks/useInView.ts new file mode 100644 index 0000000000..f9e9258f44 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useInView.ts @@ -0,0 +1,63 @@ +import { useCallback, useRef, useState } from 'react'; + +interface IntersectionOptions extends IntersectionObserverInit { + /** Only trigger the inView callback once */ + triggerOnce?: boolean; + /** Call this function whenever the in view state changes */ + onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; +} + +/** + * A custom React hook that provides the ability to track whether an element is in view + * based on the IntersectionObserver API. + * + * @param {IntersectionOptions} params - IntersectionObserver configuration options. + * @returns {{ + * inView: boolean, + * ref: (element: HTMLElement | null) => void + * }} An object containing the current inView status and a ref function to attach to the target element. + */ +export const useInView = (params: IntersectionOptions) => { + const [inView, setInView] = useState(false); + const observerRef = useRef(null); + const thresholds = Array.isArray(params.threshold) ? params.threshold : [params.threshold || 0]; + const internalOnChange = useRef(); + + internalOnChange.current = params.onChange; + + const ref = useCallback((element: HTMLElement | null) => { + // Callback refs are called with null to clear the value, so we rely on that to cleanup the observer. (ref: https://react.dev/learn/manipulating-the-dom-with-refs#how-to-manage-a-list-of-refs-using-a-ref-callback) + if (!element) { + if (observerRef.current) { + observerRef.current.disconnect(); + } + return; + } + + observerRef.current = new IntersectionObserver( + entries => { + entries.forEach(entry => { + const _inView = entry.isIntersecting && thresholds.some(threshold => entry.intersectionRatio >= threshold); + + setInView(_inView); + + if (internalOnChange.current) { + internalOnChange.current(_inView, entry); + } + }); + }, + { + root: params.root, + rootMargin: params.rootMargin, + threshold: thresholds, + }, + ); + + observerRef.current.observe(element); + }, []); + + return { + inView, + ref, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useLoadingStatus.ts b/packages/clerk-js/src/ui-retheme/hooks/useLoadingStatus.ts new file mode 100644 index 0000000000..95f9aec950 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useLoadingStatus.ts @@ -0,0 +1,21 @@ +import { useSafeState } from './useSafeState'; + +type Status = 'idle' | 'loading' | 'error'; + +export const useLoadingStatus = (initialState?: { status: Status; metadata?: Metadata | undefined }) => { + const [state, setState] = useSafeState<{ status: Status; metadata?: Metadata | undefined }>({ + status: 'idle', + metadata: undefined, + ...initialState, + }); + + return { + status: state.status, + setIdle: (metadata?: Metadata) => setState({ status: 'idle', metadata }), + setError: (metadata?: Metadata) => setState({ status: 'error', metadata }), + setLoading: (metadata?: Metadata) => setState({ status: 'loading', metadata }), + loadingMetadata: state.status === 'loading' ? state.metadata : undefined, + isLoading: state.status === 'loading', + isIdle: state.status === 'idle', + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useLocalStorage.ts b/packages/clerk-js/src/ui-retheme/hooks/useLocalStorage.ts new file mode 100644 index 0000000000..ee5dfc834c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useLocalStorage.ts @@ -0,0 +1,33 @@ +import React from 'react'; + +export function useLocalStorage(key: string, initialValue: T) { + key = 'clerk:' + key; + const [storedValue, setStoredValue] = React.useState(() => { + if (typeof window === 'undefined') { + return initialValue; + } + + try { + const item = window.localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + return initialValue; + } + }); + + const setValue = React.useCallback((value: ((stored: T) => T) | T) => { + if (typeof window === 'undefined') { + console.warn(`Tried setting localStorage key "${key}" even though environment is not a client`); + } + + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + } catch (error) { + console.error(error); + } + }, []); + + return [storedValue, setValue] as const; +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useNavigateToFlowStart.ts b/packages/clerk-js/src/ui-retheme/hooks/useNavigateToFlowStart.ts new file mode 100644 index 0000000000..79a1b5275b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useNavigateToFlowStart.ts @@ -0,0 +1,16 @@ +import { useRouter } from '../router'; + +export const useNavigateToFlowStart = () => { + const router = useRouter(); + const navigateToFlowStart = async () => { + const to = '/' + router.basePath + router.flowStartPath; + if (to !== router.currentPath) { + return router.navigate(to); + } + + if (router.urlStateParam?.path) { + return router.navigate('/' + router.basePath + router.urlStateParam?.startPath); + } + }; + return { navigateToFlowStart }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/usePassword.ts b/packages/clerk-js/src/ui-retheme/hooks/usePassword.ts new file mode 100644 index 0000000000..1fcce87607 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/usePassword.ts @@ -0,0 +1,110 @@ +import { noop } from '@clerk/shared'; +import type { PasswordValidation } from '@clerk/types'; +import { useCallback, useMemo } from 'react'; + +import type { UsePasswordCbs, UsePasswordConfig } from '../../utils/passwords/password'; +import { createValidatePassword } from '../../utils/passwords/password'; +import { localizationKeys, useLocalizations } from '../localization'; +import type { FormControlState } from '../utils'; +import { generateErrorTextUtil } from './usePasswordComplexity'; + +export const usePassword = (config: UsePasswordConfig, callbacks?: UsePasswordCbs) => { + const { t, locale } = useLocalizations(); + const { + onValidationError = noop, + onValidationSuccess = noop, + onValidationWarning = noop, + onValidationInfo = noop, + onValidationComplexity, + } = callbacks || {}; + + const onValidate = useCallback( + (res: PasswordValidation) => { + /** + * Failed complexity rules always have priority + */ + if (Object.values(res?.complexity || {}).length > 0) { + const message = generateErrorTextUtil({ + config, + t, + failedValidations: res.complexity, + locale, + }); + + if (res.complexity?.min_length) { + return onValidationInfo(message); + } + + return onValidationError(message); + } + + /** + * Failed strength + */ + if (res?.strength?.state === 'fail') { + const error = res.strength.keys.map(localizationKey => t(localizationKeys(localizationKey as any))).join(' '); + return onValidationError(error); + } + + /** + * Password meets all criteria but could be stronger + */ + if (res?.strength?.state === 'pass') { + const error = res.strength.keys.map(localizationKey => t(localizationKeys(localizationKey as any))).join(' '); + return onValidationWarning(error); + } + + /** + * Password meets all criteria and is strong + */ + return onValidationSuccess(); + }, + [callbacks, t, locale], + ); + + const validatePassword = useMemo(() => { + return createValidatePassword(config, { + onValidation: onValidate, + onValidationComplexity, + }); + }, [onValidate]); + + return { + validatePassword, + }; +}; + +export const useConfirmPassword = ({ + passwordField, + confirmPasswordField, +}: { + passwordField: FormControlState; + confirmPasswordField: FormControlState; +}) => { + const { t } = useLocalizations(); + const checkPasswordMatch = useCallback( + (confirmPassword: string) => passwordField.value === confirmPassword, + [passwordField.value], + ); + + const isPasswordMatch = useMemo( + () => checkPasswordMatch(confirmPasswordField.value), + [checkPasswordMatch, confirmPasswordField.value], + ); + + const setConfirmPasswordFeedback = useCallback( + (password: string) => { + if (checkPasswordMatch(password)) { + confirmPasswordField.setSuccess(t(localizationKeys('formFieldError__matchingPasswords'))); + } else { + confirmPasswordField.setError(t(localizationKeys('formFieldError__notMatchingPasswords'))); + } + }, + [confirmPasswordField.setError, confirmPasswordField.setSuccess, t, checkPasswordMatch], + ); + + return { + setConfirmPasswordFeedback, + isPasswordMatch, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/usePasswordComplexity.ts b/packages/clerk-js/src/ui-retheme/hooks/usePasswordComplexity.ts new file mode 100644 index 0000000000..45d4b0f155 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/usePasswordComplexity.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { ComplexityErrors, UsePasswordComplexityConfig } from '../../utils/passwords/complexity'; +import { validate } from '../../utils/passwords/complexity'; +import type { LocalizationKey } from '../localization'; +import { localizationKeys, useLocalizations } from '../localization'; +import { addFullStop, createListFormat } from '../utils'; + +const errorMessages: Record, [string, string] | string> = { + max_length: ['unstable__errors.passwordComplexity.maximumLength', 'length'], + min_length: ['unstable__errors.passwordComplexity.minimumLength', 'length'], + require_numbers: 'unstable__errors.passwordComplexity.requireNumbers', + require_lowercase: 'unstable__errors.passwordComplexity.requireLowercase', + require_uppercase: 'unstable__errors.passwordComplexity.requireUppercase', + require_special_char: 'unstable__errors.passwordComplexity.requireSpecialCharacter', +}; + +export const generateErrorTextUtil = ({ + config, + failedValidations, + locale, + t, +}: { + config: UsePasswordComplexityConfig; + failedValidations: ComplexityErrors | undefined; + locale: string; + t: (localizationKey: LocalizationKey | string | undefined) => string; +}) => { + if (!failedValidations || Object.keys(failedValidations).length === 0) { + return ''; + } + + // show min length error first by itself + const hasMinLengthError = failedValidations?.min_length || false; + + const messages = Object.entries(failedValidations) + .filter(k => (hasMinLengthError ? k[0] === 'min_length' : true)) + .filter(([, v]) => !!v) + .map(([k]) => { + const localizedKey = errorMessages[k as keyof typeof errorMessages]; + if (Array.isArray(localizedKey)) { + const [lk, attr] = localizedKey; + return t(localizationKeys(lk as any, { [attr]: config[k as keyof UsePasswordComplexityConfig] as any })); + } + return t(localizationKeys(localizedKey as any)); + }); + + const messageWithPrefix = createListFormat(messages, locale); + + return addFullStop( + `${t(localizationKeys('unstable__errors.passwordComplexity.sentencePrefix'))} ${messageWithPrefix}`, + ); +}; + +export const usePasswordComplexity = (config: UsePasswordComplexityConfig) => { + const [password, _setPassword] = useState(''); + const [failedValidations, setFailedValidations] = useState(); + const { t, locale } = useLocalizations(); + + // Populates failedValidations state + useEffect(() => { + getComplexity(''); + }, []); + + const hasPassedComplexity = useMemo( + () => !!password && Object.keys(failedValidations || {}).length === 0, + [failedValidations, password], + ); + + const hasFailedComplexity = useMemo( + () => !!password && Object.keys(failedValidations || {}).length > 0, + [failedValidations, password], + ); + + const generateErrorText = useCallback( + (failedValidations: ComplexityErrors | undefined) => { + return generateErrorTextUtil({ + config, + t, + locale, + failedValidations, + }); + }, + [t, locale], + ); + + const failedValidationsText = useMemo(() => generateErrorText(failedValidations), [failedValidations]); + + const getComplexity = useCallback((password: string) => { + _setPassword(password); + const complexity = validate(password, config); + setFailedValidations(complexity); + return { + failedValidationsText: generateErrorText(complexity), + }; + }, []); + + return { + password, + getComplexity, + failedValidations, + failedValidationsText, + hasFailedComplexity, + hasPassedComplexity, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/usePopover.ts b/packages/clerk-js/src/ui-retheme/hooks/usePopover.ts new file mode 100644 index 0000000000..c4c7642ab6 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/usePopover.ts @@ -0,0 +1,64 @@ +import type { UseFloatingOptions } from '@floating-ui/react'; +import { autoUpdate, flip, offset, shift, useDismiss, useFloating, useFloatingNodeId } from '@floating-ui/react'; +import React, { useEffect } from 'react'; + +type UsePopoverProps = { + defaultOpen?: boolean; + placement?: UseFloatingOptions['placement']; + offset?: Parameters[0]; + autoUpdate?: boolean; + outsidePress?: boolean | ((event: MouseEvent) => boolean); + bubbles?: + | boolean + | { + escapeKey?: boolean; + outsidePress?: boolean; + }; +}; + +export type UsePopoverReturn = ReturnType; + +export const usePopover = (props: UsePopoverProps = {}) => { + const { bubbles = true, outsidePress } = props; + const [isOpen, setIsOpen] = React.useState(props.defaultOpen || false); + const nodeId = useFloatingNodeId(); + const { update, refs, strategy, x, y, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + nodeId, + whileElementsMounted: props.autoUpdate === false ? undefined : autoUpdate, + placement: props.placement || 'bottom-start', + middleware: [offset(props.offset || 6), flip(), shift()], + }); + // Names are aliased because in @floating-ui/react-dom@2.0.0 the top-level elements were removed + // This keeps the API shape for consumers of usePopover + // @see https://github.com/floating-ui/floating-ui/releases/tag/%40floating-ui%2Freact-dom%402.0.0 + const { setReference: reference, setFloating: floating } = refs; + + useDismiss(context, { + bubbles, + outsidePress, + }); + + useEffect(() => { + if (props.defaultOpen) { + update(); + } + }, []); + + const toggle = React.useCallback(() => setIsOpen(o => !o), [setIsOpen]); + const open = React.useCallback(() => setIsOpen(true), [setIsOpen]); + const close = React.useCallback(() => setIsOpen(false), [setIsOpen]); + + return { + reference, + floating, + toggle, + open, + nodeId, + close, + isOpen, + styles: { position: strategy, top: y ?? 0, left: x ?? 0 }, + context, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/usePrefersReducedMotion.ts b/packages/clerk-js/src/ui-retheme/hooks/usePrefersReducedMotion.ts new file mode 100644 index 0000000000..9d39777c58 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +const mediaQueryNoPreference = '(prefers-reduced-motion: no-preference)'; + +export function usePrefersReducedMotion() { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(true); + + useEffect(() => { + const mediaQueryList = window.matchMedia(mediaQueryNoPreference); + setPrefersReducedMotion(!window.matchMedia(mediaQueryNoPreference).matches); + + const listener = (event: MediaQueryListEvent) => { + setPrefersReducedMotion(!event.matches); + }; + + mediaQueryList.addEventListener('change', listener); + + return () => mediaQueryList.removeEventListener('change', listener); + }, []); + + return prefersReducedMotion; +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useSafeState.ts b/packages/clerk-js/src/ui-retheme/hooks/useSafeState.ts new file mode 100644 index 0000000000..cba72ee5ee --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useSafeState.ts @@ -0,0 +1,29 @@ +import React from 'react'; + +/** + * Solves/ hides the "setState on unmounted component" warning + * In 99% of cases, there is no memory leak involved, but still an annoying warning + * For more info: + * https://github.com/reactwg/react-18/discussions/82 + */ +export function useSafeState(initialState: S | (() => S)): [S, React.Dispatch>]; +export function useSafeState(): [S | undefined, React.Dispatch>]; +export function useSafeState(initialState?: S | (() => S)) { + const [state, _setState] = React.useState(initialState); + const isMountedRef = React.useRef(true); + + React.useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const setState = React.useCallback((currentState: any) => { + if (!isMountedRef.current) { + return; + } + _setState(currentState); + }, []); + + return [state, setState] as const; +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useSaml.ts b/packages/clerk-js/src/ui-retheme/hooks/useSaml.ts new file mode 100644 index 0000000000..5e350b9b23 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useSaml.ts @@ -0,0 +1,19 @@ +import type { SamlIdpSlug } from '@clerk/types'; +import { SAML_IDPS } from '@clerk/types'; + +import { iconImageUrl } from '../common/constants'; + +function getSamlProviderLogoUrl(provider: SamlIdpSlug = 'saml_custom'): string { + return iconImageUrl(SAML_IDPS[provider]?.logo); +} + +function getSamlProviderName(provider: SamlIdpSlug = 'saml_custom'): string { + return SAML_IDPS[provider]?.name; +} + +export const useSaml = () => { + return { + getSamlProviderLogoUrl, + getSamlProviderName, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useScrollLock.ts b/packages/clerk-js/src/ui-retheme/hooks/useScrollLock.ts new file mode 100644 index 0000000000..e41cbba3a1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useScrollLock.ts @@ -0,0 +1,28 @@ +/** + * Disables scroll for an element. + * Adds extra padding to prevent layout shifting + * caused by hiding the scrollbar. + */ +export const useScrollLock = (el: T) => { + let oldPaddingRightPx: string; + let oldOverflow: string; + + const disableScroll = () => { + oldPaddingRightPx = getComputedStyle(el).paddingRight; + oldOverflow = getComputedStyle(el).overflow; + const oldWidth = el.clientWidth; + el.style.overflow = 'hidden'; + const currentWidth = el.clientWidth; + const oldPaddingRight = Number.parseInt(oldPaddingRightPx.replace('px', '')); + el.style.paddingRight = `${currentWidth - oldWidth + oldPaddingRight}px`; + }; + + const enableScroll = () => { + el.style.overflow = oldOverflow; + if (oldPaddingRightPx) { + el.style.paddingRight = oldPaddingRightPx; + } + }; + + return { disableScroll, enableScroll }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useSearchInput.ts b/packages/clerk-js/src/ui-retheme/hooks/useSearchInput.ts new file mode 100644 index 0000000000..2408585ff4 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useSearchInput.ts @@ -0,0 +1,35 @@ +import type { ChangeEventHandler } from 'react'; +import React from 'react'; + +type Unarray = T extends Array ? U : T; + +type UseSearchInputProps = { + items: Items; + comparator: (term: string, item: Unarray, itemTerm?: string) => boolean; + searchTermForItem?: (item: Unarray) => string; +}; + +type UseSearchInputReturn = { filteredItems: Items; searchInputProps: any }; + +export const useSearchInput = >( + props: UseSearchInputProps, +): UseSearchInputReturn => { + const { items, comparator, searchTermForItem } = props; + const [searchTerm, setSearchTerm] = React.useState(''); + const onChange: ChangeEventHandler = e => setSearchTerm(e.target.value || ''); + + const searchTermMap = React.useMemo(() => { + type TermMap = Map, string | undefined>; + return items.reduce((acc, item) => { + (acc as TermMap).set(item, searchTermForItem?.(item)); + return acc; + }, new Map() as TermMap) as TermMap; + }, [items]); + + const filteredItems = React.useMemo( + () => (searchTerm ? items.filter(i => comparator(searchTerm, i, searchTermMap.get(i))) : items), + [items, searchTerm], + ) as Items; + + return { searchInputProps: { onChange, value: searchTerm }, filteredItems }; +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useSetSessionWithTimeout.ts b/packages/clerk-js/src/ui-retheme/hooks/useSetSessionWithTimeout.ts new file mode 100644 index 0000000000..dd4508101f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useSetSessionWithTimeout.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; + +import { useCoreClerk, useSignInContext } from '../contexts'; +import { useRouter } from '../router'; + +export const useSetSessionWithTimeout = (delay = 2000) => { + const { queryString } = useRouter(); + const { setActive } = useCoreClerk(); + const { navigateAfterSignIn } = useSignInContext(); + + useEffect(() => { + let timeoutId: ReturnType; + const queryParams = new URLSearchParams(queryString); + const createdSessionId = queryParams.get('createdSessionId'); + if (createdSessionId) { + timeoutId = setTimeout(() => { + void setActive({ session: createdSessionId, beforeEmit: navigateAfterSignIn }); + }, delay); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [setActive, navigateAfterSignIn, queryString]); +}; diff --git a/packages/clerk-js/src/ui-retheme/hooks/useSupportEmail.ts b/packages/clerk-js/src/ui-retheme/hooks/useSupportEmail.ts new file mode 100644 index 0000000000..bcd04b6844 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useSupportEmail.ts @@ -0,0 +1,24 @@ +import React from 'react'; + +import { buildEmailAddress } from '../../utils'; +import { useCoreClerk, useEnvironment, useOptions } from '../contexts'; + +export function useSupportEmail(): string { + const Clerk = useCoreClerk(); + const { supportEmail: supportEmailFromOptions } = useOptions(); + const { displayConfig } = useEnvironment(); + const { supportEmail: supportEmailFromEnvironment } = displayConfig; + + const supportDomain = React.useMemo( + () => + supportEmailFromOptions || + supportEmailFromEnvironment || + buildEmailAddress({ + localPart: 'support', + frontendApi: Clerk.frontendApi, + }), + [Clerk.frontendApi, supportEmailFromOptions, supportEmailFromEnvironment], + ); + + return supportDomain; +} diff --git a/packages/clerk-js/src/ui-retheme/hooks/useWindowEventListener.ts b/packages/clerk-js/src/ui-retheme/hooks/useWindowEventListener.ts new file mode 100644 index 0000000000..843aba51d7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/hooks/useWindowEventListener.ts @@ -0,0 +1,15 @@ +import React from 'react'; +type EventType = keyof WindowEventMap; + +export const useWindowEventListener = (eventOrEvents: EventType | EventType[] | undefined, cb: () => void): void => { + React.useEffect(() => { + const events = [eventOrEvents].flat().filter(x => !!x) as EventType[]; + if (!events.length) { + return; + } + events.forEach(e => window.addEventListener(e, cb)); + return () => { + events.forEach(e => window.removeEventListener(e, cb)); + }; + }, [eventOrEvents, cb]); +}; diff --git a/packages/clerk-js/src/ui-retheme/icons/arrow-left.svg b/packages/clerk-js/src/ui-retheme/icons/arrow-left.svg new file mode 100644 index 0000000000..65a98939a7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/arrow-left.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/arrow-right.svg b/packages/clerk-js/src/ui-retheme/icons/arrow-right.svg new file mode 100644 index 0000000000..3e626bf728 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/arrow-right.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/auth-app.svg b/packages/clerk-js/src/ui-retheme/icons/auth-app.svg new file mode 100644 index 0000000000..b3c06abfa7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/auth-app.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/billing.svg b/packages/clerk-js/src/ui-retheme/icons/billing.svg new file mode 100644 index 0000000000..78203d4581 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/billing.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/caret.svg b/packages/clerk-js/src/ui-retheme/icons/caret.svg new file mode 100644 index 0000000000..f9f4dccba6 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/caret.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/chat-alt.svg b/packages/clerk-js/src/ui-retheme/icons/chat-alt.svg new file mode 100644 index 0000000000..468207244e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/chat-alt.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/check-circle.svg b/packages/clerk-js/src/ui-retheme/icons/check-circle.svg new file mode 100644 index 0000000000..d8cc3defc9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/check-circle.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/clipboard.svg b/packages/clerk-js/src/ui-retheme/icons/clipboard.svg new file mode 100644 index 0000000000..3b5e478022 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/clipboard.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/close.svg b/packages/clerk-js/src/ui-retheme/icons/close.svg new file mode 100644 index 0000000000..7b53a7993d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/close.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/cog-filled.svg b/packages/clerk-js/src/ui-retheme/icons/cog-filled.svg new file mode 100644 index 0000000000..bdd05c6a55 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/cog-filled.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/cog.svg b/packages/clerk-js/src/ui-retheme/icons/cog.svg new file mode 100644 index 0000000000..de7315c02f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/cog.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/device-laptop.svg b/packages/clerk-js/src/ui-retheme/icons/device-laptop.svg new file mode 100644 index 0000000000..c24e4088d7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/device-laptop.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/device-mobile.svg b/packages/clerk-js/src/ui-retheme/icons/device-mobile.svg new file mode 100644 index 0000000000..e824ebfeb4 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/device-mobile.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/dot-circle-horizontal.svg b/packages/clerk-js/src/ui-retheme/icons/dot-circle-horizontal.svg new file mode 100644 index 0000000000..1f5b269d13 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/dot-circle-horizontal.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/email.svg b/packages/clerk-js/src/ui-retheme/icons/email.svg new file mode 100644 index 0000000000..b4f033d986 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/email.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/exclamation-circle.svg b/packages/clerk-js/src/ui-retheme/icons/exclamation-circle.svg new file mode 100644 index 0000000000..62a3f24594 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/exclamation-circle.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/exclamation-triangle.svg b/packages/clerk-js/src/ui-retheme/icons/exclamation-triangle.svg new file mode 100644 index 0000000000..946e6bcec5 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/exclamation-triangle.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/eye-slash.svg b/packages/clerk-js/src/ui-retheme/icons/eye-slash.svg new file mode 100644 index 0000000000..514a1e7b04 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/eye-slash.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/eye.svg b/packages/clerk-js/src/ui-retheme/icons/eye.svg new file mode 100644 index 0000000000..d2ba4e089a --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/eye.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/folder.svg b/packages/clerk-js/src/ui-retheme/icons/folder.svg new file mode 100644 index 0000000000..11eddc522b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/folder.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/index.ts b/packages/clerk-js/src/ui-retheme/icons/index.ts new file mode 100644 index 0000000000..61073b8f45 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/index.ts @@ -0,0 +1,48 @@ +// @ts-nocheck +/** + * TypeScript configuration (typings) is not correctly configured for all projects. + * Consequently, the files are correctly imported but the TS checker emits errors. + * The above no-check is safe, as webpack will not allow compilation if for example a file is not resolved. + */ +export { default as ArrowLeftIcon } from './arrow-left.svg'; +export { default as ArrowRightIcon } from './arrow-right.svg'; +export { default as AuthApp } from './auth-app.svg'; +export { default as Billing } from './billing.svg'; +export { default as Caret } from './caret.svg'; +export { default as ChatAltIcon } from './chat-alt.svg'; +export { default as CheckCircle } from './check-circle.svg'; +export { default as Clipboard } from './clipboard.svg'; +export { default as Close } from './close.svg'; +export { default as CogFilled } from './cog-filled.svg'; +export { default as DeviceLaptop } from './device-laptop.svg'; +export { default as DeviceMobile } from './device-mobile.svg'; +export { default as DotCircle } from './dot-circle-horizontal.svg'; +export { default as Email } from './email.svg'; +export { default as ExclamationCircle } from './exclamation-circle.svg'; +export { default as ExclamationTriangle } from './exclamation-triangle.svg'; +export { default as EyeSlash } from './eye-slash.svg'; +export { default as Eye } from './eye.svg'; +export { default as Folder } from './folder.svg'; +export { default as InformationCircle } from './information-circle.svg'; +export { default as LinkIcon } from './link.svg'; +export { default as LockClosedIcon } from './lock-closed.svg'; +export { default as LogoMark } from './logo-mark-new.svg'; +export { default as MagnifyingGlass } from './magnifying-glass.svg'; +export { default as Menu } from './menu.svg'; +export { default as Mobile } from './mobile-small.svg'; +export { default as PencilEdit } from './pencil-edit.svg'; +export { default as Pencil } from './pencil.svg'; +export { default as Plus } from './plus.svg'; +export { default as QuestionMark } from './question-mark.svg'; +export { default as RequestAuthIcon } from './request-auth.svg'; +export { default as Selector } from './selector.svg'; +export { default as SignOutDouble } from './signout-double.svg'; +export { default as SignOut } from './signout.svg'; +export { default as SwitchArrows } from './switch-arrows.svg'; +export { default as ThreeDots } from './threeDots.svg'; +export { default as TickShield } from './tick-shield.svg'; +export { default as Times } from './times.svg'; +export { default as Trash } from './trash.svg'; +export { default as Upload } from './upload.svg'; +export { default as User } from './user.svg'; +export { default as UserAdd } from './userAdd.svg'; diff --git a/packages/clerk-js/src/ui-retheme/icons/information-circle.svg b/packages/clerk-js/src/ui-retheme/icons/information-circle.svg new file mode 100644 index 0000000000..9ab89bd3d2 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/information-circle.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/clerk-js/src/ui-retheme/icons/link.svg b/packages/clerk-js/src/ui-retheme/icons/link.svg new file mode 100644 index 0000000000..d33f12d24c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/link.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/lock-closed.svg b/packages/clerk-js/src/ui-retheme/icons/lock-closed.svg new file mode 100644 index 0000000000..87c5d0551b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/lock-closed.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/logo-mark-new.svg b/packages/clerk-js/src/ui-retheme/icons/logo-mark-new.svg new file mode 100644 index 0000000000..daf10d5e78 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/logo-mark-new.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/logo-mark.svg b/packages/clerk-js/src/ui-retheme/icons/logo-mark.svg new file mode 100644 index 0000000000..9144580157 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/logo-mark.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/magnifying-glass.svg b/packages/clerk-js/src/ui-retheme/icons/magnifying-glass.svg new file mode 100644 index 0000000000..324be823f5 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/magnifying-glass.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/menu.svg b/packages/clerk-js/src/ui-retheme/icons/menu.svg new file mode 100644 index 0000000000..f4a81c36c4 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/menu.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/mobile-small.svg b/packages/clerk-js/src/ui-retheme/icons/mobile-small.svg new file mode 100644 index 0000000000..a0f8dea5ff --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/mobile-small.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/mobile.svg b/packages/clerk-js/src/ui-retheme/icons/mobile.svg new file mode 100644 index 0000000000..1dab910829 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/mobile.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/pencil-edit.svg b/packages/clerk-js/src/ui-retheme/icons/pencil-edit.svg new file mode 100644 index 0000000000..bc383febbc --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/pencil-edit.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/pencil.svg b/packages/clerk-js/src/ui-retheme/icons/pencil.svg new file mode 100644 index 0000000000..6060104b72 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/pencil.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/clerk-js/src/ui-retheme/icons/plus.svg b/packages/clerk-js/src/ui-retheme/icons/plus.svg new file mode 100644 index 0000000000..16d6d1e8e3 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/plus.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/question-mark.svg b/packages/clerk-js/src/ui-retheme/icons/question-mark.svg new file mode 100644 index 0000000000..d34c38a603 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/question-mark.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/request-auth.svg b/packages/clerk-js/src/ui-retheme/icons/request-auth.svg new file mode 100644 index 0000000000..8ac7ea863e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/request-auth.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/selector.svg b/packages/clerk-js/src/ui-retheme/icons/selector.svg new file mode 100644 index 0000000000..f445d1f9d3 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/selector.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/signout-double.svg b/packages/clerk-js/src/ui-retheme/icons/signout-double.svg new file mode 100644 index 0000000000..52a5d03f00 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/signout-double.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/signout.svg b/packages/clerk-js/src/ui-retheme/icons/signout.svg new file mode 100644 index 0000000000..3dba008c14 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/signout.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/switch-arrows.svg b/packages/clerk-js/src/ui-retheme/icons/switch-arrows.svg new file mode 100644 index 0000000000..bc00400b7e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/switch-arrows.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/threeDots.svg b/packages/clerk-js/src/ui-retheme/icons/threeDots.svg new file mode 100644 index 0000000000..b399e31a9c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/threeDots.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/tick-shield.svg b/packages/clerk-js/src/ui-retheme/icons/tick-shield.svg new file mode 100644 index 0000000000..498672049b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/tick-shield.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/times.svg b/packages/clerk-js/src/ui-retheme/icons/times.svg new file mode 100644 index 0000000000..722fa62240 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/times.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/trash.svg b/packages/clerk-js/src/ui-retheme/icons/trash.svg new file mode 100644 index 0000000000..8c41a793fd --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/trash.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/upload.svg b/packages/clerk-js/src/ui-retheme/icons/upload.svg new file mode 100644 index 0000000000..1b0da8c091 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/upload.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/user.svg b/packages/clerk-js/src/ui-retheme/icons/user.svg new file mode 100644 index 0000000000..d762fd8778 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/user.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/icons/userAdd.svg b/packages/clerk-js/src/ui-retheme/icons/userAdd.svg new file mode 100644 index 0000000000..50a226e627 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/icons/userAdd.svg @@ -0,0 +1 @@ + diff --git a/packages/clerk-js/src/ui-retheme/lazyModules/common.ts b/packages/clerk-js/src/ui-retheme/lazyModules/common.ts new file mode 100644 index 0000000000..81f7d71faf --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/lazyModules/common.ts @@ -0,0 +1,3 @@ +import '../contexts/index'; + +export { createRoot } from 'react-dom/client'; diff --git a/packages/clerk-js/src/ui-retheme/lazyModules/components.ts b/packages/clerk-js/src/ui-retheme/lazyModules/components.ts new file mode 100644 index 0000000000..f4fe93f9a4 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/lazyModules/components.ts @@ -0,0 +1,82 @@ +import { lazy } from 'react'; + +const componentImportPaths = { + SignIn: () => import(/* webpackChunkName: "signin" */ './../components/SignIn'), + SignUp: () => import(/* webpackChunkName: "signup" */ './../components/SignUp'), + UserButton: () => import(/* webpackChunkName: "userbutton" */ './../components/UserButton'), + UserProfile: () => import(/* webpackChunkName: "userprofile" */ './../components/UserProfile'), + CreateOrganization: () => import(/* webpackChunkName: "createorganization" */ './../components/CreateOrganization'), + OrganizationProfile: () => + import(/* webpackChunkName: "organizationprofile" */ './../components/OrganizationProfile'), + OrganizationSwitcher: () => + import(/* webpackChunkName: "organizationswitcher" */ './../components/OrganizationSwitcher'), + OrganizationList: () => import(/* webpackChunkName: "organizationlist" */ './../components/OrganizationList'), + ImpersonationFab: () => import(/* webpackChunkName: "impersonationfab" */ './../components/ImpersonationFab'), +} as const; + +export const SignIn = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignIn }))); + +export const SignInModal = lazy(() => componentImportPaths.SignIn().then(module => ({ default: module.SignInModal }))); + +export const SignUp = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUp }))); + +export const SignUpModal = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUpModal }))); + +export const UserButton = lazy(() => + componentImportPaths.UserButton().then(module => ({ default: module.UserButton })), +); +export const UserProfile = lazy(() => + componentImportPaths.UserProfile().then(module => ({ default: module.UserProfile })), +); +export const UserProfileModal = lazy(() => + componentImportPaths.UserProfile().then(module => ({ default: module.UserProfileModal })), +); +export const CreateOrganization = lazy(() => + componentImportPaths.CreateOrganization().then(module => ({ default: module.CreateOrganization })), +); + +export const CreateOrganizationModal = lazy(() => + componentImportPaths.CreateOrganization().then(module => ({ default: module.CreateOrganizationModal })), +); + +export const OrganizationProfile = lazy(() => + componentImportPaths.OrganizationProfile().then(module => ({ default: module.OrganizationProfile })), +); + +export const OrganizationProfileModal = lazy(() => + componentImportPaths.OrganizationProfile().then(module => ({ default: module.OrganizationProfileModal })), +); + +export const OrganizationSwitcher = lazy(() => + componentImportPaths.OrganizationSwitcher().then(module => ({ default: module.OrganizationSwitcher })), +); + +export const OrganizationList = lazy(() => + componentImportPaths.OrganizationList().then(module => ({ default: module.OrganizationList })), +); + +export const ImpersonationFab = lazy(() => + componentImportPaths.ImpersonationFab().then(module => ({ default: module.ImpersonationFab })), +); + +export const preloadComponent = async (component: unknown) => { + return componentImportPaths[component as keyof typeof componentImportPaths]?.(); +}; + +export const ClerkComponents = { + SignIn, + SignUp, + UserButton, + UserProfile, + OrganizationSwitcher, + OrganizationList, + OrganizationProfile, + CreateOrganization, + SignInModal, + SignUpModal, + UserProfileModal, + OrganizationProfileModal, + CreateOrganizationModal, +}; + +export type ClerkComponentName = keyof typeof ClerkComponents; diff --git a/packages/clerk-js/src/ui-retheme/lazyModules/providers.tsx b/packages/clerk-js/src/ui-retheme/lazyModules/providers.tsx new file mode 100644 index 0000000000..a76e0ac432 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/lazyModules/providers.tsx @@ -0,0 +1,130 @@ +import type { Appearance } from '@clerk/types'; +import React, { lazy, Suspense } from 'react'; + +import type { FlowMetadata } from '../elements'; +import type { ThemableCssProp } from '../styledSystem'; +import type { ClerkComponentName } from './components'; +import { ClerkComponents } from './components'; + +const CoreClerkContextWrapper = lazy(() => import('../contexts').then(m => ({ default: m.CoreClerkContextWrapper }))); +const EnvironmentProvider = lazy(() => import('../contexts').then(m => ({ default: m.EnvironmentProvider }))); +const OptionsProvider = lazy(() => import('../contexts').then(m => ({ default: m.OptionsProvider }))); +const AppearanceProvider = lazy(() => import('../customizables').then(m => ({ default: m.AppearanceProvider }))); +const VirtualRouter = lazy(() => import('../router').then(m => ({ default: m.VirtualRouter }))); +const InternalThemeProvider = lazy(() => import('../styledSystem').then(m => ({ default: m.InternalThemeProvider }))); +const Portal = lazy(() => import('./../portal')); +const FlowMetadataProvider = lazy(() => import('./../elements').then(m => ({ default: m.FlowMetadataProvider }))); +const Modal = lazy(() => import('./../elements').then(m => ({ default: m.Modal }))); + +type LazyProvidersProps = React.PropsWithChildren<{ clerk: any; environment: any; options: any; children: any }>; + +export const LazyProviders = (props: LazyProvidersProps) => { + return ( + + + {props.children} + + + ); +}; + +type _AppearanceProviderProps = Parameters[0]; +type AppearanceProviderProps = { + globalAppearance?: _AppearanceProviderProps['globalAppearance']; + appearanceKey: _AppearanceProviderProps['appearanceKey']; + componentAppearance?: _AppearanceProviderProps['appearance']; +}; +type LazyComponentRendererProps = React.PropsWithChildren< + { + node: PortalProps['node']; + componentName: any; + componentProps: any; + } & AppearanceProviderProps +>; + +type PortalProps = Parameters[0]; + +export const LazyComponentRenderer = (props: LazyComponentRendererProps) => { + return ( + + + + ); +}; + +type ModalProps = Parameters[0]; + +type LazyModalRendererProps = React.PropsWithChildren< + { + componentName: ClerkComponentName; + flowName?: FlowMetadata['flow']; + startPath?: string; + onClose?: ModalProps['handleClose']; + onExternalNavigate?: () => any; + modalContainerSx?: ThemableCssProp; + modalContentSx?: ThemableCssProp; + } & AppearanceProviderProps +>; + +export const LazyModalRenderer = (props: LazyModalRendererProps) => { + return ( + + + + + + {props.startPath ? ( + + + {props.children} + + + ) : ( + props.children + )} + + + + + + ); +}; + +/** + * This component automatically mounts when impersonating, without a user action. + * We want to hotload the /ui dependencies only if we're actually impersonating. + */ +export const LazyImpersonationFabProvider = ( + props: React.PropsWithChildren<{ globalAppearance: Appearance | undefined }>, +) => { + return ( + + + {props.children} + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/localization/__tests__/applyTokensToString.test.ts b/packages/clerk-js/src/ui-retheme/localization/__tests__/applyTokensToString.test.ts new file mode 100644 index 0000000000..ed9ff255d7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/__tests__/applyTokensToString.test.ts @@ -0,0 +1,132 @@ +import { applyTokensToString } from '../applyTokensToString'; + +describe('applyTokensToString', function () { + const tokens = { + applicationName: 'myApp', + 'user.firstName': 'nikos', + identifier: 'nikos@hello.dev', + provider: 'google', + date: new Date('2021-12-31T22:00:00.000Z'), + dateString: '2021-12-31T22:00:00.000Z', + dateNumber: 1640988000000, + }; + + const cases = [ + ['Continue with {{provider|titleize}}', 'Continue with Google'], + ['Continue with {{ provider | titleize }}', 'Continue with Google'], + ['Welcome to {{applicationName}}, have fun', 'Welcome to myApp, have fun'], + ['Welcome to {{applicationName|titleize}}, have fun', 'Welcome to MyApp, have fun'], + ['Welcome to {{ applicationName| titleize}}, have fun', 'Welcome to MyApp, have fun'], + ['This is an {{unknown}} token', 'This is an {{unknown}} token'], + ['This is an {{applicationName|unknown}} modifier', 'This is an myApp modifier'], + ['This includes no token', 'This includes no token'], + [ + 'This supports multiple tokens {{user.firstName }} - {{ identifier |titleize}}', + 'This supports multiple tokens nikos - Nikos@hello.dev', + ], + ['', ''], + [undefined, ''], + ]; + + it.each(cases)('.applyTokensToString(%s, tokens) => %s', (input, expected) => { + expect(applyTokensToString(input, tokens as any)).toEqual(expected); + }); + + describe('Date related tokens and modifiers', () => { + beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + const tokens = { + date: new Date('2021-12-31T22:00:00.000Z'), + dateString: '2021-12-31T22:00:00.000Z', + dateNumber: 1640988000000, + errorString: 'test_error_case', + }; + + const cases = [ + ["Last {{ date | weekday('en-US','long') }} at {{ date | timeString('en-US') }}", 'Last Friday at 10:00 PM'], + ["Last {{ date | weekday('fr-FR','long') }} at {{ date | timeString('fr-FR') }}", 'Last vendredi at 22:00'], + [ + "Last {{ date | weekday('fr-FR','long') | titleize }} at {{ date | timeString('fr-FR') }}", + 'Last Vendredi at 22:00', + ], + [ + "Προηγούμενη {{ date | weekday('el-GR','long') }} στις {{ date | timeString('el-GR') }}", + 'Προηγούμενη Παρασκευή στις 10:00 μ.μ.', + ], + [ + "Προηγούμενη {{ date | weekday('el-GR') }} στις {{ date | timeString('el-GR') }}", + 'Προηγούμενη Παρασκευή στις 10:00 μ.μ.', + ], + [ + "Last {{ dateString | weekday('en-US','long') }} at {{ dateString | timeString('en-US') }}", + 'Last Friday at 10:00 PM', + ], + [ + "Last {{ dateNumber | weekday('en-US','long') }} at {{ dateNumber | timeString('en-US') }}", + 'Last Friday at 10:00 PM', + ], + ['Last {{ date | weekday }} at {{ date | timeString }}', 'Last Friday at 10:00 PM'], + ]; + + it.each(cases)('.applyTokensToString(%s, tokens) => %s', (input, expected) => { + expect(applyTokensToString(input, tokens as any)).toEqual(expected); + }); + + describe('Modifiers', () => { + describe('weekday', () => { + const cases = [ + ['{{ date | weekday }}', 'Friday'], + ['{{ dateString | weekday }}', 'Friday'], + ['{{ dateNumber | weekday }}', 'Friday'], + ['{{ date | weekday("en-US") }}', 'Friday'], + ['{{ date | weekday("fr-FR") }}', 'vendredi'], + ['{{ date | weekday("fr-FR") | titleize }}', 'Vendredi'], + ['{{ date | weekday("en-US", "long") }}', 'Friday'], + ['{{ date | weekday("en-US", "short") }}', 'Fri'], + ['{{ date | weekday("en-US", "narrow") }}', 'F'], + ['{{ date | weekday("fr-FR", "narrow") }}', 'V'], + ['{{ errorString | weekday("en-US") }}', ''], + ]; + + it.each(cases)('.applyTokensToString(%s, tokens) => %s', (input, expected) => { + expect(applyTokensToString(input, tokens as any)).toEqual(expected); + }); + }); + + describe('timeString', () => { + const cases = [ + ['{{ date | timeString }}', '10:00 PM'], + ['{{ dateString | timeString }}', '10:00 PM'], + ['{{ dateNumber | timeString }}', '10:00 PM'], + ['{{ date | timeString("en-US") }}', '10:00 PM'], + ['{{ date | timeString("el-GR") }}', '10:00 μ.μ.'], + ['{{ date | timeString("el-GR") | titleize }}', '10:00 μ.μ.'], + ['{{ date | timeString("fr-FR") }}', '22:00'], + ['{{ errorString | timeString("en-US") }}', ''], + ]; + + it.each(cases)('.applyTokensToString(%s, tokens) => %s', (input, expected) => { + expect(applyTokensToString(input, tokens as any)).toEqual(expected); + }); + }); + + describe('numeric', () => { + const cases = [ + ['{{ date | numeric }}', '12/31/2021'], + ['{{ dateString | numeric }}', '12/31/2021'], + ['{{ dateNumber | numeric }}', '12/31/2021'], + ['{{ date | numeric("en-US") }}', '12/31/2021'], + ['{{ date | numeric("fr-FR") }}', '31/12/2021'], + ['{{ date | numeric("fr-FR") | titleize }}', '31/12/2021'], + ['{{ errorString | numeric("en-US") }}', ''], + ]; + + it.each(cases)('.applyTokensToString(%s, tokens) => %s', (input, expected) => { + expect(applyTokensToString(input, tokens as any)).toEqual(expected); + }); + }); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/localization/__tests__/makeLocalizable.test.tsx b/packages/clerk-js/src/ui-retheme/localization/__tests__/makeLocalizable.test.tsx new file mode 100644 index 0000000000..debf569c8b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/__tests__/makeLocalizable.test.tsx @@ -0,0 +1,137 @@ +import { bindCreateFixtures, render, renderHook, screen } from '../../../testUtils'; +import { + Badge, + Button, + FormErrorText, + FormLabel, + Heading, + Link, + localizationKeys, + SimpleButton, + Text, + useLocalizations, +} from '../../customizables'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +const localizableElements = [ + { name: 'Badge', el: Badge }, + { name: 'Button', el: Button }, + { name: 'FormErrorText', el: FormErrorText }, + { name: 'FormLabel', el: FormLabel }, + { name: 'Heading', el: Heading }, + { name: 'Link', el: Link }, + { name: 'SimpleButton', el: SimpleButton }, + { name: 'Text', el: Text }, +]; + +describe('Test localizable components', () => { + it.each(localizableElements)( + '$name renders the localization value based on the localization key', + async ({ el: El }) => { + const { wrapper } = await createFixtures(); + + render(, { wrapper }); + + const { result } = renderHook(() => useLocalizations(), { wrapper }); + + const localizedValue = result.current.t(localizationKeys('backButton')); + + screen.getByText(localizedValue); + }, + ); + + it.each(localizableElements)('$name renders the children if no localization key is provided', async ({ el: El }) => { + const { wrapper } = await createFixtures(); + + render(test, { wrapper }); + + screen.getByText('test'); + }); + + it.each(localizableElements)( + '$name only renders the localization value if both children and key are provided', + async ({ el: El }) => { + const { wrapper } = await createFixtures(); + + render(test, { wrapper }); + + const { result } = renderHook(() => useLocalizations(), { wrapper }); + + const localizedValue = result.current.t(localizationKeys('backButton')); + + screen.getByText(localizedValue); + }, + ); + + it.each(localizableElements)('$name renders the global token if provided with one', async ({ el: El }) => { + const { wrapper } = await createFixtures(); + + render(, { + wrapper, + }); + + screen.getByText(`Continue with Test_provider`); // this key makes use of titleize + }); + + it.each(localizableElements)('$name renders the global date token if provided with one', async ({ el: El }) => { + const { wrapper } = await createFixtures(); + + const date = new Date('11/12/1999'); + + render(, { + wrapper, + }); + + screen.getByText('11/12/1999'); // this key makes use of numeric('en-US') + }); + + it('translates errors using the values provided in unstable_errors', async () => { + const { wrapper, fixtures } = await createFixtures(); + fixtures.options.localization = { + unstable__errors: { + form_identifier_not_found: 'form_identifier_not_found', + form_password_pwned: 'form_password_pwned', + form_username_invalid_length: 'form_username_invalid_length', + form_username_invalid_character: 'form_username_invalid_character', + form_param_format_invalid: 'form_param_format_invalid', + form_password_length_too_short: 'form_password_length_too_short', + form_param_nil: 'form_param_nil', + form_code_incorrect: 'form_code_incorrect', + form_password_incorrect: 'form_password_incorrect', + not_allowed_access: 'not_allowed_access', + form_identifier_exists: 'form_identifier_exists', + form_identifier_exists__username: 'form_identifier_exists__username', + form_identifier_exists__email_address: 'form_identifier_exists__email_address', + }, + }; + const { result } = renderHook(() => useLocalizations(), { wrapper }); + const { translateError } = result.current; + expect(translateError({ code: 'code-does-not-exist', message: 'message' })).toBe('message'); + expect(translateError({ code: 'form_identifier_not_found', message: 'message' } as any)).toBe( + 'form_identifier_not_found', + ); + expect(translateError({ code: 'form_password_pwned', message: 'message' })).toBe('form_password_pwned'); + expect(translateError({ code: 'form_username_invalid_length', message: 'message' })).toBe( + 'form_username_invalid_length', + ); + expect(translateError({ code: 'form_username_invalid_character', message: 'message' })).toBe( + 'form_username_invalid_character', + ); + expect(translateError({ code: 'form_param_format_invalid', message: 'message' })).toBe('form_param_format_invalid'); + expect(translateError({ code: 'form_password_length_too_short', message: 'message' })).toBe( + 'form_password_length_too_short', + ); + expect(translateError({ code: 'form_param_nil', message: 'message' })).toBe('form_param_nil'); + expect(translateError({ code: 'form_code_incorrect', message: 'message' })).toBe('form_code_incorrect'); + expect(translateError({ code: 'form_password_incorrect', message: 'message' })).toBe('form_password_incorrect'); + expect(translateError({ code: 'not_allowed_access', message: 'message' })).toBe('not_allowed_access'); + expect(translateError({ code: 'form_identifier_exists', message: 'message' })).toBe('form_identifier_exists'); + expect( + translateError({ code: 'form_identifier_exists', message: 'message', meta: { paramName: 'username' } }), + ).toBe('form_identifier_exists__username'); + expect( + translateError({ code: 'form_identifier_exists', message: 'message', meta: { paramName: 'email_address' } }), + ).toBe('form_identifier_exists__email_address'); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/localization/__tests__/parseLocalization.test.tsx b/packages/clerk-js/src/ui-retheme/localization/__tests__/parseLocalization.test.tsx new file mode 100644 index 0000000000..6c4871ef2f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/__tests__/parseLocalization.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { bindCreateFixtures, renderHook } from '../../../testUtils'; +import { OptionsProvider } from '../../contexts'; +import { localizationKeys, useLocalizations } from '../../customizables'; +import { defaultResource } from '../defaultEnglishResource'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('Localization parsing and replacing', () => { + it('Localization value returned from hook is equal to the value declared in the defaultResource when no localization options are passed', async () => { + const { wrapper: Wrapper } = await createFixtures(); + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const localizedValue = result.current.t(localizationKeys('backButton')); + expect(localizedValue).toBe(defaultResource.backButton); + }); + + it('Localization value returned from hook is equal to the value declared in the defaultResource', async () => { + const { wrapper: Wrapper } = await createFixtures(); + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const localizedValue = result.current.t(localizationKeys('backButton')); + expect(localizedValue).toBe('test'); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/localization/applyTokensToString.ts b/packages/clerk-js/src/ui-retheme/localization/applyTokensToString.ts new file mode 100644 index 0000000000..fb4c8d7a12 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/applyTokensToString.ts @@ -0,0 +1,97 @@ +import { useCoreClerk, useEnvironment } from '../contexts'; +import { MODIFIERS } from './localizationModifiers'; + +export type GlobalTokens = { + applicationName: string; + 'signIn.identifier': string; + 'user.firstName': string; + 'user.lastName': string; + 'user.username': string; + 'user.primaryEmailAddress': string; + 'user.primaryPhoneNumber': string; +}; + +// TODO: This type can be narrowed down when we know all +// global and local tokens used throughout the codebase +export type Tokens = GlobalTokens & Record; + +type Token = keyof Tokens | string; +type Modifier = { modifierName: keyof typeof MODIFIERS; params: string[] }; +type TokenExpression = { token: Token; modifiers: Modifier[] }; + +export const applyTokensToString = (s: string | undefined, tokens: Tokens): string => { + if (!s) { + return ''; + } + const { normalisedString, expressions } = parseTokensFromLocalizedString(s, tokens); + return applyTokenExpressions(normalisedString, expressions, tokens); +}; + +export const useGlobalTokens = (): GlobalTokens => { + const { applicationName } = useEnvironment().displayConfig; + const { client, user } = useCoreClerk(); + const { signIn } = client; + + return { + applicationName, + 'signIn.identifier': signIn.identifier || '', + 'user.username': user?.username || '', + 'user.firstName': user?.firstName || '', + 'user.lastName': user?.lastName || '', + 'user.primaryEmailAddress': user?.primaryEmailAddress?.emailAddress || '', + 'user.primaryPhoneNumber': user?.primaryPhoneNumber?.phoneNumber || '', + }; +}; + +const parseTokensFromLocalizedString = ( + s: string, + tokens: Tokens, +): { normalisedString: string; expressions: TokenExpression[] } => { + const matches = (s.match(/{{.+?}}/g) || []).map(m => m.replace(/[{}]/g, '')); + const parsedMatches = matches.map(m => m.split('|').map(m => m.trim())); + const expressions = parsedMatches + .filter(match => match[0] in tokens) + .map(([token, ...modifiers]) => ({ + token, + modifiers: modifiers.map(m => getModifierWithParams(m)).filter(m => assertKnownModifier(m.modifierName)), + })); + + let normalisedString = s; + expressions.forEach(({ token }) => { + // Marking the position of each token with _++token++_ so we can easily + // replace it with its localized value in the next step + normalisedString = normalisedString.replace(/{{.+?}}/, `_++${token}++_`); + }); + return { expressions: expressions as TokenExpression[], normalisedString }; +}; + +const applyTokenExpressions = (s: string, expressions: TokenExpression[], tokens: Tokens) => { + expressions.forEach(({ token, modifiers }) => { + const value = modifiers.reduce((acc, mod) => { + try { + return MODIFIERS[mod.modifierName](acc, ...mod.params); + } catch (e) { + console.warn(e); + return ''; + } + }, tokens[token]); + s = s.replace(`_++${token}++_`, value); + }); + return s; +}; + +const assertKnownModifier = (s: any): s is Modifier => Object.prototype.hasOwnProperty.call(MODIFIERS, s); + +const getModifierWithParams = (modifierExpression: string) => { + const parts = modifierExpression + .split(/[(,)]/g) + .map(m => m.trim()) + .filter(m => !!m); + if (parts.length === 1) { + const [modifierName] = parts; + return { modifierName, params: [] }; + } else { + const [modifierName, ...params] = parts; + return { modifierName, params: params.map(p => p.replace(/['"]+/g, '')) }; + } +}; diff --git a/packages/clerk-js/src/ui-retheme/localization/defaultEnglishResource.ts b/packages/clerk-js/src/ui-retheme/localization/defaultEnglishResource.ts new file mode 100644 index 0000000000..b59b108a9d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/defaultEnglishResource.ts @@ -0,0 +1,4 @@ +import { enUS } from '@clerk/localizations'; +import type { DeepRequired } from '@clerk/types'; + +export const defaultResource = enUS as DeepRequired; diff --git a/packages/clerk-js/src/ui-retheme/localization/index.ts b/packages/clerk-js/src/ui-retheme/localization/index.ts new file mode 100644 index 0000000000..7c3c6b8995 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/index.ts @@ -0,0 +1,4 @@ +export * from './makeLocalizable'; +export * from './localizationKeys'; +export * from './defaultEnglishResource'; +export * from './parseLocalization'; diff --git a/packages/clerk-js/src/ui-retheme/localization/localizationKeys.ts b/packages/clerk-js/src/ui-retheme/localization/localizationKeys.ts new file mode 100644 index 0000000000..016eea05e9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/localizationKeys.ts @@ -0,0 +1,80 @@ +import type { PathValue, RecordToPath } from '@clerk/types'; + +import type { defaultResource } from './defaultEnglishResource'; + +type Value = string | number | boolean | Date; +type Whitespace = ' ' | '\t' | '\n' | '\r'; + +type Trim = T extends `${Whitespace}${infer Rest}` + ? Trim + : T extends `${infer Rest}${Whitespace}` + ? Trim + : T extends string + ? T + : never; + +type RemovePipeUtils = Text extends `${infer Left}|titleize${infer Right}` + ? `${Left}${Right}` + : Text; + +type FindBlocks = Text extends `${string}{{${infer Right}` + ? ReadBlock<'', Right, ''> extends [infer Block, infer Tail] + ? [Block, ...FindBlocks] + : never + : []; + +type TupleFindBlocks = T extends readonly [infer First, ...infer Rest] + ? [...FindBlocks, ...TupleFindBlocks] + : []; + +type ReadBlock< + Block extends string, + Tail extends string, + Depth extends string, +> = Tail extends `${infer L1}}}${infer R1}` + ? L1 extends `${infer L2}{{${infer R2}` + ? ReadBlock<`${Block}${L2}{{`, `${R2}}}${R1}`, `${Depth}+`> + : Depth extends `+${infer Rest}` + ? ReadBlock<`${Block}${L1}}}`, R1, Rest> + : [`${Block}${L1}`, R1] + : []; + +/** Parse block, return variables with types and recursively find nested blocks within */ +type ParseBlock = Block extends `${infer Name},${infer Format},${infer Rest}` + ? { [K in Trim]: VariableType> } & TupleParseBlock>> + : Block extends `${infer Name},${infer Format}` + ? { [K in Trim]: VariableType> } + : { [K in Trim]: Value }; + +/** Parse block for each tuple entry */ +type TupleParseBlock = T extends readonly [infer First, ...infer Rest] + ? ParseBlock & TupleParseBlock + : unknown; + +type VariableType = T extends 'number' | 'plural' | 'selectordinal' + ? number + : T extends 'date' | 'time' + ? Date + : Value; + +export type GetICUArgs> = TupleParseBlock< + T extends readonly string[] ? TupleFindBlocks : FindBlocks +>; + +type DefaultLocalizationKey = RecordToPath; +type LocalizationKeyToValue

= PathValue; + +// @ts-ignore +type LocalizationKeyToParams

= GetICUArgs>; + +export type LocalizationKey = { + key: string; + params: Record | undefined; +}; + +export const localizationKeys = >( + key: Key, + params?: keyof Params extends never ? never : Params, +): LocalizationKey => { + return { key, params } as LocalizationKey; +}; diff --git a/packages/clerk-js/src/ui-retheme/localization/localizationModifiers.ts b/packages/clerk-js/src/ui-retheme/localization/localizationModifiers.ts new file mode 100644 index 0000000000..1e84382923 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/localizationModifiers.ts @@ -0,0 +1,35 @@ +import { normalizeDate, titleize } from '@clerk/shared'; + +const timeString = (val: Date | string | number, locale?: string) => { + try { + return new Intl.DateTimeFormat(locale || 'en-US', { timeStyle: 'short' }).format(normalizeDate(val)); + } catch (e) { + console.warn(e); + return ''; + } +}; + +const weekday = (val: Date | string | number, locale?: string, weekday?: 'long' | 'short' | 'narrow' | undefined) => { + try { + return new Intl.DateTimeFormat(locale || 'en-US', { weekday: weekday || 'long' }).format(normalizeDate(val)); + } catch (e) { + console.warn(e); + return ''; + } +}; + +const numeric = (val: Date | number | string, locale?: string) => { + try { + return new Intl.DateTimeFormat(locale || 'en-US').format(normalizeDate(val)); + } catch (e) { + console.warn(e); + return ''; + } +}; + +export const MODIFIERS = { + titleize, + timeString, + weekday, + numeric, +} as const; diff --git a/packages/clerk-js/src/ui-retheme/localization/makeLocalizable.tsx b/packages/clerk-js/src/ui-retheme/localization/makeLocalizable.tsx new file mode 100644 index 0000000000..96f4604ed9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/makeLocalizable.tsx @@ -0,0 +1,115 @@ +import { isClerkRuntimeError } from '@clerk/shared/error'; +import type { ClerkAPIError, ClerkRuntimeError, LocalizationResource } from '@clerk/types'; +import React from 'react'; + +import { useOptions } from '../contexts'; +import { readObjectPath } from '../utils'; +import type { GlobalTokens } from './applyTokensToString'; +import { applyTokensToString, useGlobalTokens } from './applyTokensToString'; +import { defaultResource } from './defaultEnglishResource'; +import type { LocalizationKey } from './localizationKeys'; +import { localizationKeys } from './localizationKeys'; +import { useParsedLocalizationResource } from './parseLocalization'; + +type Localizable = T & { + localizationKey?: LocalizationKey | string; +}; + +type LocalizablePrimitive = React.FunctionComponent>; + +export const makeLocalizable = (Component: React.FunctionComponent

): LocalizablePrimitive

=> { + const localizableComponent = React.forwardRef((props: Localizable, ref) => { + const parsedResource = useParsedLocalizationResource(); + const { localizationKey, ...restProps } = props; + const globalTokens = useGlobalTokens(); + + if (!localizationKey) { + return ( + + ); + } + + if (typeof localizationKey === 'string') { + return ( + + {localizationKey} + + ); + } + + return ( + + {localizedStringFromKey(localizationKey, parsedResource, globalTokens) || restProps.children} + + ); + }); + + const displayName = Component.displayName || Component.name || 'Component'; + localizableComponent.displayName = `Localizable${displayName}`.replace('_', ''); + return localizableComponent as LocalizablePrimitive

; +}; + +export const useLocalizations = () => { + const { localization } = useOptions(); + const parsedResource = useParsedLocalizationResource(); + const globalTokens = useGlobalTokens(); + + const t = (localizationKey: LocalizationKey | string | undefined) => { + if (!localizationKey || typeof localizationKey === 'string') { + return localizationKey || ''; + } + return localizedStringFromKey(localizationKey, parsedResource, globalTokens); + }; + + const translateError = (error: ClerkRuntimeError | ClerkAPIError | string | undefined) => { + if (!error || typeof error === 'string') { + return t(error); + } + + if (isClerkRuntimeError(error)) { + return t(localizationKeys(`unstable__errors.${error.code}` as any)) || error.message; + } + + const { code, message, longMessage, meta } = (error || {}) as ClerkAPIError; + const { paramName = '' } = meta || {}; + + if (!code) { + return ''; + } + + return ( + t(localizationKeys(`unstable__errors.${code}__${paramName}` as any)) || + t(localizationKeys(`unstable__errors.${code}` as any)) || + longMessage || + message + ); + }; + + return { t, translateError, locale: localization?.locale || defaultResource?.locale }; +}; + +const localizationKeyAttribute = (localizationKey: LocalizationKey) => { + return localizationKey.key; +}; + +const localizedStringFromKey = ( + localizationKey: LocalizationKey, + resource: LocalizationResource, + globalTokens: GlobalTokens, +): string => { + const key = localizationKey.key; + const base = readObjectPath(resource, key) as string; + const params = localizationKey.params; + const tokens = { ...globalTokens, ...params }; + return applyTokensToString(base || '', tokens); +}; diff --git a/packages/clerk-js/src/ui-retheme/localization/parseLocalization.ts b/packages/clerk-js/src/ui-retheme/localization/parseLocalization.ts new file mode 100644 index 0000000000..63ecb5605c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/localization/parseLocalization.ts @@ -0,0 +1,29 @@ +import type { DeepPartial, LocalizationResource } from '@clerk/types'; +import { dequal as deepEqual } from 'dequal'; + +import { useOptions } from '../contexts'; +import { fastDeepMergeAndReplace } from '../utils'; +import { defaultResource } from './defaultEnglishResource'; + +let cache: LocalizationResource | undefined; +let prev: DeepPartial | undefined; + +const parseLocalizationResource = ( + userDefined: DeepPartial, + base: LocalizationResource, +): LocalizationResource => { + if (!cache || (!!prev && prev !== userDefined && !deepEqual(userDefined, prev))) { + prev = userDefined; + const res = {} as LocalizationResource; + fastDeepMergeAndReplace(base, res); + fastDeepMergeAndReplace(userDefined, res); + cache = res; + return cache; + } + return cache; +}; + +export const useParsedLocalizationResource = () => { + const { localization } = useOptions(); + return parseLocalizationResource(localization || {}, defaultResource as any as LocalizationResource); +}; diff --git a/packages/clerk-js/src/ui-retheme/portal/index.tsx b/packages/clerk-js/src/ui-retheme/portal/index.tsx new file mode 100644 index 0000000000..29755a1b37 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/portal/index.tsx @@ -0,0 +1,45 @@ +import React, { Suspense } from 'react'; +import ReactDOM from 'react-dom'; + +import { PRESERVED_QUERYSTRING_PARAMS } from '../../core/constants'; +import { clerkErrorPathRouterMissingPath } from '../../core/errors'; +import { ComponentContext } from '../contexts'; +import { HashRouter, PathRouter } from '../router'; +import type { AvailableComponentCtx } from '../types'; + +type PortalProps> = { + node: HTMLDivElement; + component: React.FunctionComponent | React.ComponentClass; + // Aligning this with props attributes of ComponentControls + props?: PropsType & { path?: string; routing?: string }; +} & Pick; + +export default class Portal extends React.PureComponent> { + render(): React.ReactPortal { + const { props, component, componentName, node } = this.props; + + const el = ( + + {React.createElement(component, props)} + + ); + + if (props?.routing === 'path') { + if (!props?.path) { + clerkErrorPathRouterMissingPath(componentName); + } + + return ReactDOM.createPortal( + + {el} + , + node, + ); + } + + return ReactDOM.createPortal({el}, node); + } +} diff --git a/packages/clerk-js/src/ui-retheme/primitives/Alert.tsx b/packages/clerk-js/src/ui-retheme/primitives/Alert.tsx new file mode 100644 index 0000000000..1635683348 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Alert.tsx @@ -0,0 +1,31 @@ +import type { StyleVariants } from '../styledSystem'; +import { common, createVariants } from '../styledSystem'; +import type { FlexProps } from './Flex'; +import { Flex } from './Flex'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + padding: `${theme.space.$3} ${theme.space.$4}`, + backgroundColor: theme.colors.$blackAlpha50, + ...common.borderVariants(theme).normal, + }, + variants: {}, +})); + +export type AlertProps = FlexProps & StyleVariants; + +export const Alert = (props: AlertProps): JSX.Element => { + return ( + // @ts-ignore + + {props.children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/AlertIcon.tsx b/packages/clerk-js/src/ui-retheme/primitives/AlertIcon.tsx new file mode 100644 index 0000000000..9a7c6a1428 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/AlertIcon.tsx @@ -0,0 +1,34 @@ +import { ExclamationCircle, ExclamationTriangle } from '../icons'; +import type { StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + marginRight: theme.space.$2x5, + width: theme.sizes.$4, + height: theme.sizes.$4, + }, + variants: { + colorScheme: { + danger: { color: theme.colors.$danger500 }, + warning: { color: theme.colors.$warning500 }, + success: { color: theme.colors.$success500 }, + primary: { color: theme.colors.$primary500 }, + }, + }, +})); + +type OwnProps = { variant: 'danger' | 'warning' }; + +export type AlertIconProps = OwnProps & StyleVariants; + +export const AlertIcon = (props: AlertIconProps): JSX.Element => { + const { variant, ...rest } = props; + const Icon = variant === 'warning' ? ExclamationCircle : ExclamationTriangle; + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/Badge.tsx b/packages/clerk-js/src/ui-retheme/primitives/Badge.tsx new file mode 100644 index 0000000000..0d492aea27 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Badge.tsx @@ -0,0 +1,59 @@ +import type { PropsOfComponent, StyleVariants } from '../styledSystem'; +import { common, createCssVariables, createVariants } from '../styledSystem'; +import { colors } from '../utils'; +import { Flex } from './Flex'; + +const vars = createCssVariables('accent', 'bg'); + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + color: vars.accent, + backgroundColor: vars.bg, + borderRadius: theme.radii.$sm, + padding: `${theme.space.$0x5} ${theme.space.$1x5}`, + display: 'inline-flex', + }, + variants: { + textVariant: { ...common.textVariants(theme) }, + colorScheme: { + primary: { + [vars.accent]: theme.colors.$primary500, + [vars.bg]: colors.setAlpha(theme.colors.$primary400, 0.2), + }, + danger: { + [vars.accent]: theme.colors.$danger500, + [vars.bg]: theme.colors.$danger100, + }, + neutral: { + [vars.accent]: theme.colors.$blackAlpha600, + [vars.bg]: theme.colors.$blackAlpha200, + }, + success: { + [vars.accent]: theme.colors.$success500, + [vars.bg]: colors.setAlpha(theme.colors.$success50, 0.2), + }, + warning: { + [vars.accent]: theme.colors.$warning600, + [vars.bg]: theme.colors.$warning100, + }, + }, + }, + defaultVariants: { + colorScheme: 'primary', + textVariant: 'smallMedium', + }, +})); + +// @ts-ignore +export type BadgeProps = PropsOfComponent & StyleVariants; + +export const Badge = (props: BadgeProps) => { + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/Box.tsx b/packages/clerk-js/src/ui-retheme/primitives/Box.tsx new file mode 100644 index 0000000000..5b0d941b6d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Box.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import type { AsProp, PrimitiveProps, StateProps, StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; +import { applyDataStateProps } from './applyDataStateProps'; + +const { applyVariants } = createVariants(() => ({ + base: { + boxSizing: 'inherit', + }, + variants: {}, +})); + +export type BoxProps = StateProps & PrimitiveProps<'div'> & AsProp & StyleVariants; + +export const Box = React.forwardRef((props, ref) => { + // Simply ignore non-native props if they reach here + const { as: As = 'div', ...rest } = props; + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Button.tsx b/packages/clerk-js/src/ui-retheme/primitives/Button.tsx new file mode 100644 index 0000000000..2ca1002de5 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Button.tsx @@ -0,0 +1,229 @@ +import React from 'react'; + +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { common, createCssVariables, createVariants } from '../styledSystem'; +import { colors } from '../utils'; +import { applyDataStateProps } from './applyDataStateProps'; +import { Flex } from './Flex'; +import { Spinner } from './Spinner'; + +const vars = createCssVariables('accent', 'accentDark', 'accentDarker', 'accentLighter', 'accentLightest', 'border'); + +const { applyVariants, filterProps } = createVariants((theme, props: OwnProps) => { + return { + base: { + margin: 0, + padding: 0, + border: 0, + outline: 0, + userSelect: 'none', + cursor: 'pointer', + backgroundColor: 'unset', + color: 'currentColor', + borderRadius: theme.radii.$md, + ...common.centeredFlex('inline-flex'), + ...common.disabled(theme), + transitionProperty: theme.transitionProperty.$common, + transitionDuration: theme.transitionDuration.$controls, + }, + variants: { + textVariant: common.textVariants(theme), + size: { + iconLg: { minHeight: theme.sizes.$14, width: theme.sizes.$14 }, + xs: { minHeight: theme.sizes.$1x5, padding: `${theme.space.$1x5} ${theme.space.$1x5}` }, + sm: { + minHeight: theme.sizes.$8, + padding: `${theme.space.$2} ${theme.space.$3x5}`, + }, + md: { + minHeight: theme.sizes.$9, + padding: `${theme.space.$2x5} ${theme.space.$5}`, + letterSpacing: theme.sizes.$xxs, + }, + }, + colorScheme: { + primary: { + [vars.accentLightest]: colors.setAlpha(theme.colors.$primary400, 0.3), + [vars.accentLighter]: colors.setAlpha(theme.colors.$primary500, 0.3), + [vars.accent]: theme.colors.$primary500, + [vars.accentDark]: theme.colors.$primary600, + [vars.accentDarker]: theme.colors.$primary700, + }, + danger: { + [vars.accentLightest]: colors.setAlpha(theme.colors.$danger400, 0.3), + [vars.accentLighter]: colors.setAlpha(theme.colors.$danger500, 0.3), + [vars.accent]: theme.colors.$danger500, + [vars.accentDark]: theme.colors.$danger600, + [vars.accentDarker]: theme.colors.$danger700, + }, + neutral: { + [vars.border]: theme.colors.$blackAlpha200, + [vars.accentLightest]: theme.colors.$blackAlpha50, + [vars.accentLighter]: theme.colors.$blackAlpha300, + [vars.accent]: theme.colors.$colorText, + [vars.accentDark]: theme.colors.$blackAlpha600, + [vars.accentDarker]: theme.colors.$blackAlpha700, + }, + }, + variant: { + solid: { + backgroundColor: vars.accent, + color: theme.colors.$colorTextOnPrimaryBackground, + '&:hover': { backgroundColor: vars.accentDark }, + '&:focus': props.hoverAsFocus ? { backgroundColor: vars.accentDark } : undefined, + '&:active': { backgroundColor: vars.accentDarker }, + }, + outline: { + border: theme.borders.$normal, + borderColor: vars.accentLighter, + color: vars.accent, + '&:hover': { backgroundColor: vars.accentLightest }, + '&:focus': props.hoverAsFocus ? { backgroundColor: vars.accentLightest } : undefined, + '&:active': { backgroundColor: vars.accentLighter }, + }, + ghost: { + color: vars.accent, + '&:hover': { backgroundColor: vars.accentLightest }, + '&:focus': props.hoverAsFocus ? { backgroundColor: vars.accentLightest } : undefined, + '&:active': { backgroundColor: vars.accentLighter }, + }, + icon: { + color: vars.accent, + border: theme.borders.$normal, + borderRadius: theme.radii.$lg, + borderColor: vars.border, + '&:hover': { backgroundColor: vars.accentLightest }, + '&:focus': props.hoverAsFocus ? { backgroundColor: vars.accentLightest } : undefined, + '&:active': { backgroundColor: vars.accentLighter }, + }, + ghostIcon: { + color: vars.accent, + minHeight: theme.sizes.$6, + width: theme.sizes.$6, + padding: `${theme.space.$1} ${theme.space.$1}`, + '&:hover': { color: vars.accentDark }, + '&:focus': props.hoverAsFocus ? { backgroundColor: vars.accentDark } : undefined, + '&:active': { color: vars.accentDarker }, + }, + link: { + ...common.textVariants(theme).smallRegular, + minHeight: 'fit-content', + height: 'fit-content', + width: 'fit-content', + textTransform: 'none', + padding: 0, + color: vars.accent, + '&:hover': { textDecoration: 'underline' }, + '&:focus': props.hoverAsFocus ? { textDecoration: 'underline' } : undefined, + '&:active': { color: vars.accentDark }, + }, + roundWrapper: { padding: 0, margin: 0, height: 'unset', width: 'unset', minHeight: 'unset' }, + }, + block: { + true: { width: '100%' }, + }, + focusRing: { + true: { ...common.focusRing(theme) }, + }, + }, + defaultVariants: { + textVariant: 'buttonRegularRegular', + colorScheme: 'primary', + variant: 'solid', + size: 'md', + focusRing: true, + }, + }; +}); +type OwnProps = PrimitiveProps<'button'> & { + isLoading?: boolean; + loadingText?: string; + isDisabled?: boolean; + isActive?: boolean; + hoverAsFocus?: boolean; +}; +type ButtonProps = OwnProps & StyleVariants; + +const Button = React.forwardRef((props, ref) => { + const parsedProps: ButtonProps = { ...props, isDisabled: props.isDisabled || props.isLoading }; + const { + isLoading, + isDisabled, + + hoverAsFocus, + loadingText, + children, + onClick: onClickProp, + ...rest + } = filterProps(parsedProps); + + const onClick: React.MouseEventHandler = e => { + if (rest.type !== 'submit') { + e.preventDefault(); + } + return onClickProp?.(e); + }; + + return ( + + ); +}); + +const SimpleButton = React.forwardRef((props, ref) => { + const parsedProps: ButtonProps = { ...props, isDisabled: props.isDisabled || props.isLoading }; + + const { loadingText, isDisabled, hoverAsFocus, children, onClick: onClickProp, ...rest } = filterProps(parsedProps); + + const onClick: React.MouseEventHandler = e => { + if (rest.type !== 'submit') { + e.preventDefault(); + } + return onClickProp?.(e); + }; + + return ( + + ); +}); + +export { Button, SimpleButton }; diff --git a/packages/clerk-js/src/ui-retheme/primitives/Flex.tsx b/packages/clerk-js/src/ui-retheme/primitives/Flex.tsx new file mode 100644 index 0000000000..aea983ee5e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Flex.tsx @@ -0,0 +1,81 @@ +import React from 'react'; + +import type { StateProps, StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + display: 'flex', + }, + variants: { + direction: { + row: { flexDirection: 'row' }, + col: { flexDirection: 'column' }, + rowReverse: { flexDirection: 'row-reverse' }, + columnReverse: { flexDirection: 'column-reverse' }, + }, + align: { + start: { alignItems: 'flex-start' }, + center: { alignItems: 'center' }, + end: { alignItems: 'flex-end' }, + stretch: { alignItems: 'stretch' }, + baseline: { alignItems: 'baseline' }, + }, + justify: { + start: { justifyContent: 'flex-start' }, + center: { justifyContent: 'center' }, + end: { justifyContent: 'flex-end' }, + between: { justifyContent: 'space-between' }, + }, + wrap: { + noWrap: { flexWrap: 'nowrap' }, + wrap: { flexWrap: 'wrap' }, + wrapReverse: { flexWrap: 'wrap-reverse' }, + }, + gap: { + 1: { gap: theme.space.$1 }, + 2: { gap: theme.space.$2 }, + 3: { gap: theme.space.$3 }, + 4: { gap: theme.space.$4 }, + 5: { gap: theme.space.$5 }, + 6: { gap: theme.space.$6 }, + 7: { gap: theme.space.$7 }, + 8: { gap: theme.space.$8 }, + 9: { gap: theme.space.$9 }, + }, + center: { + true: { justifyContent: 'center', alignItems: 'center' }, + }, + }, + defaultVariants: { + direction: 'row', + align: 'stretch', + justify: 'start', + wrap: 'noWrap', + }, +})); + +// @ts-ignore +export type FlexProps = StateProps & BoxProps & StyleVariants; + +export const Flex = React.forwardRef((props, ref) => { + return ( + + ); +}); + +export const Col = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Form.tsx b/packages/clerk-js/src/ui-retheme/primitives/Form.tsx new file mode 100644 index 0000000000..c7cbf7471c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Form.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import type { PrimitiveProps } from '../styledSystem'; +import type { FlexProps } from './Flex'; +import { Flex } from './Flex'; + +export type FormProps = PrimitiveProps<'form'> & Omit; + +export const Form = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/FormControl.tsx b/packages/clerk-js/src/ui-retheme/primitives/FormControl.tsx new file mode 100644 index 0000000000..9056e551f8 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/FormControl.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { Flex } from './Flex'; +import type { FormControlProps } from './hooks'; +import { FormControlContextProvider } from './hooks'; + +/** + * @deprecated Use Field.Root + * Each controlled field should have their own UI wrapper. + * Field.Root is just a Provider + */ +export const FormControl = (props: React.PropsWithChildren) => { + const { + hasError, + id, + isRequired, + setError, + setInfo, + clearFeedback, + setSuccess, + setWarning, + setHasPassedComplexity, + ...rest + } = props; + return ( + + + {props.children} + + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/FormErrorText.tsx b/packages/clerk-js/src/ui-retheme/primitives/FormErrorText.tsx new file mode 100644 index 0000000000..7b5c806464 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/FormErrorText.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef } from 'react'; + +import { Icon } from '../customizables'; +import { ExclamationCircle } from '../icons'; +import type { StyleVariants } from '../styledSystem'; +import { animations, createVariants } from '../styledSystem'; +import { useFormControl } from './hooks'; +import { Text } from './Text'; + +const { applyVariants } = createVariants(theme => ({ + base: { + willChange: 'transform, opacity, height', + marginTop: theme.sizes.$2, + animation: `${animations.textInSmall} ${theme.transitionDuration.$fast}`, + display: 'flex', + gap: theme.sizes.$1, + position: 'absolute', + top: '0', + }, + variants: {}, +})); + +type FormErrorTextProps = React.PropsWithChildren>; + +export const FormErrorText = forwardRef((props, ref) => { + const { hasError, errorMessageId } = useFormControl() || {}; + + if (!hasError && !props.children) { + return null; + } + + const { children, ...rest } = props; + + return ( + + + {children} + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/FormInfoText.tsx b/packages/clerk-js/src/ui-retheme/primitives/FormInfoText.tsx new file mode 100644 index 0000000000..2aa8b23f88 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/FormInfoText.tsx @@ -0,0 +1,26 @@ +import { forwardRef } from 'react'; + +import type { FormTextProps } from './FormSuccessText'; +import { applyVariants } from './FormSuccessText'; +import { useFormControl } from './hooks'; +import { Text } from './Text'; + +export const FormInfoText = forwardRef((props, ref) => { + const { hasError, errorMessageId } = useFormControl() || {}; + + if (!hasError && !props.children) { + return null; + } + + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/FormLabel.tsx b/packages/clerk-js/src/ui-retheme/primitives/FormLabel.tsx new file mode 100644 index 0000000000..ea0123b083 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/FormLabel.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import type { PrimitiveProps, RequiredProp, StateProps, StyleVariants } from '../styledSystem'; +import { common, createVariants } from '../styledSystem'; +import { applyDataStateProps } from './applyDataStateProps'; +import { useFormControl } from './hooks'; + +const { applyVariants } = createVariants(theme => ({ + base: { + color: theme.colors.$colorText, + ...common.textVariants(theme).smallMedium, + ...common.disabled(theme), + }, + variants: {}, +})); + +type OwnProps = React.PropsWithChildren; + +type FormLabelProps = PrimitiveProps<'label'> & StyleVariants & OwnProps & RequiredProp; + +export const FormLabel = (props: FormLabelProps) => { + const { id } = useFormControl(); + const { isRequired, htmlFor: htmlForProp, ...rest } = props; + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/FormSuccessText.tsx b/packages/clerk-js/src/ui-retheme/primitives/FormSuccessText.tsx new file mode 100644 index 0000000000..fa7e969936 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/FormSuccessText.tsx @@ -0,0 +1,43 @@ +import React, { forwardRef } from 'react'; + +import { Icon } from '../customizables'; +import { CheckCircle } from '../icons'; +import type { StyleVariants } from '../styledSystem'; +import { animations, createVariants } from '../styledSystem'; +import { Text } from './Text'; + +export const { applyVariants } = createVariants(theme => ({ + base: { + willChange: 'transform, opacity, height', + marginTop: theme.sizes.$2, + animation: `${animations.textInSmall} ${theme.transitionDuration.$fast}`, + display: 'flex', + gap: theme.sizes.$1, + position: 'absolute', + top: '0', + }, + variants: {}, +})); + +export type FormTextProps = React.PropsWithChildren>; + +export const FormSuccessText = forwardRef((props, ref) => { + const { children, ...rest } = props; + + return ( + + + {children} + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/FormWarningText.tsx b/packages/clerk-js/src/ui-retheme/primitives/FormWarningText.tsx new file mode 100644 index 0000000000..9d58a80ea7 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/FormWarningText.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from 'react'; + +import { Icon } from '../customizables'; +import { ExclamationCircle } from '../icons'; +import type { FormTextProps } from './FormSuccessText'; +import { applyVariants } from './FormSuccessText'; +import { Text } from './Text'; + +export const FormWarningText = forwardRef((props, ref) => { + const { children, ...rest } = props; + + return ( + + + {children} + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Grid.tsx b/packages/clerk-js/src/ui-retheme/primitives/Grid.tsx new file mode 100644 index 0000000000..4d5e7dbf57 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Grid.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +import type { StateProps, StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + display: 'grid', + }, + variants: { + align: { + start: { alignItems: 'flex-start' }, + center: { alignItems: 'center' }, + end: { alignItems: 'flex-end' }, + stretch: { alignItems: 'stretch' }, + baseline: { alignItems: 'baseline' }, + }, + justify: { + start: { justifyContent: 'flex-start' }, + center: { justifyContent: 'center' }, + end: { justifyContent: 'flex-end' }, + between: { justifyContent: 'space-between' }, + around: { justifyContent: 'space-around' }, + stretch: { justifyContent: 'stretch' }, + }, + columns: { + 1: { gridTemplateColumns: '1fr' }, + 2: { gridTemplateColumns: 'repeat(2, 1fr)' }, + 3: { gridTemplateColumns: 'repeat(3, 1fr)' }, + 4: { gridTemplateColumns: 'repeat(4, 1fr)' }, + 6: { gridTemplateColumns: 'repeat(6, 1fr)' }, + }, + gap: { + 1: { gap: theme.space.$1 }, + 2: { gap: theme.space.$2 }, + 3: { gap: theme.space.$3 }, + 4: { gap: theme.space.$4 }, + 5: { gap: theme.space.$5 }, + 6: { gap: theme.space.$6 }, + 7: { gap: theme.space.$7 }, + 8: { gap: theme.space.$8 }, + 9: { gap: theme.space.$9 }, + }, + }, + defaultVariants: { + align: 'stretch', + justify: 'stretch', + wrap: 'noWrap', + }, +})); + +export type GridProps = StateProps & BoxProps & StyleVariants; + +export const Grid = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Heading.tsx b/packages/clerk-js/src/ui-retheme/primitives/Heading.tsx new file mode 100644 index 0000000000..5c44a93465 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Heading.tsx @@ -0,0 +1,35 @@ +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { common, createVariants } from '../styledSystem'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + boxSizing: 'border-box', + color: `${theme.colors.$colorText}`, + margin: 0, + }, + variants: { + textVariant: { ...common.textVariants(theme) }, + as: { + h1: { + lineHeight: theme.lineHeights.$base, + }, + }, + }, + defaultVariants: { + as: 'h1', + textVariant: 'xlargeMedium', + }, +})); + +// @ts-ignore +export type HeadingProps = PrimitiveProps<'div'> & StyleVariants & { as?: 'h1' }; + +export const Heading = (props: HeadingProps) => { + const { as: As = 'h1', ...rest } = props; + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/Icon.tsx b/packages/clerk-js/src/ui-retheme/primitives/Icon.tsx new file mode 100644 index 0000000000..264e3227cb --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Icon.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import type { StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + flexShrink: 0, + }, + variants: { + size: { + sm: { width: theme.sizes.$3, height: theme.sizes.$3 }, + md: { width: theme.sizes.$4, height: theme.sizes.$4 }, + lg: { width: theme.sizes.$5, height: theme.sizes.$5 }, + }, + colorScheme: { + success: { color: theme.colors.$success500 }, + danger: { color: theme.colors.$danger500 }, + warning: { color: theme.colors.$warning500 }, + neutral: { color: theme.colors.$blackAlpha400 }, + }, + }, + defaultVariants: { + size: 'md', + }, +})); + +// @ts-ignore +export type IconProps = StyleVariants & { + icon: React.ComponentType; +}; + +export const Icon = (props: IconProps): JSX.Element => { + const { icon: Icon, ...rest } = props; + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/Image.tsx b/packages/clerk-js/src/ui-retheme/primitives/Image.tsx new file mode 100644 index 0000000000..85a71e3ce2 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Image.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import type { PrimitiveProps, StateProps } from '../styledSystem'; +import { applyDataStateProps } from './applyDataStateProps'; + +export type ImageProps = PrimitiveProps<'img'> & StateProps; + +export const Image = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Input.tsx b/packages/clerk-js/src/ui-retheme/primitives/Input.tsx new file mode 100644 index 0000000000..9967fc30b0 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Input.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import type { PrimitiveProps, RequiredProp, StyleVariants } from '../styledSystem'; +import { common, createVariants, mqu } from '../styledSystem'; +import { useFormControl } from './hooks'; +import { useInput } from './hooks/useInput'; + +const { applyVariants, filterProps } = createVariants((theme, props) => ({ + base: { + boxSizing: 'inherit', + margin: 0, + padding: `${theme.space.$2x5} ${theme.space.$4}`, + backgroundColor: theme.colors.$colorInputBackground, + color: theme.colors.$colorInputText, + // outline support for Windows contrast themes + outline: 'transparent solid 2px', + outlineOffset: '2px', + width: props.type === 'checkbox' ? theme.sizes.$4 : '100%', + aspectRatio: props.type === 'checkbox' ? '1/1' : 'unset', + accentColor: theme.colors.$primary500, + ...common.textVariants(theme).smallRegular, + ...common.borderVariants(theme, props).normal, + ...(props.focusRing === false ? {} : common.focusRingInput(theme, props)), + ...common.disabled(theme), + [mqu.ios]: { + fontSize: theme.fontSizes.$md, + }, + ':autofill': { + animationName: 'onAutoFillStart', + }, + }, + variants: {}, +})); + +type OwnProps = { + isDisabled?: boolean; + hasError?: boolean; + focusRing?: boolean; + isSuccessful?: boolean; +}; + +export type InputProps = PrimitiveProps<'input'> & StyleVariants & OwnProps & RequiredProp; + +export const Input = React.forwardRef((props, ref) => { + const formControlProps = useFormControl() || {}; + const propsWithoutVariants = filterProps({ + ...props, + hasError: props.hasError || formControlProps.hasError, + }); + const { onChange } = useInput(propsWithoutVariants.onChange); + const { isDisabled, hasError, focusRing, isRequired, ...rest } = propsWithoutVariants; + const _disabled = isDisabled || formControlProps.isDisabled; + const _required = isRequired || formControlProps.isRequired; + const _hasError = hasError || formControlProps.hasError; + + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Link.tsx b/packages/clerk-js/src/ui-retheme/primitives/Link.tsx new file mode 100644 index 0000000000..6cedc61abf --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Link.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { common, createVariants } from '../styledSystem'; +import { applyDataStateProps } from './applyDataStateProps'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + boxSizing: 'border-box', + display: 'inline-flex', + alignItems: 'center', + margin: 0, + cursor: 'pointer', + ...common.focusRing(theme), + ...common.disabled(theme), + textDecoration: 'none', + '&:hover': { textDecoration: 'underline' }, + }, + variants: { + variant: common.textVariants(theme), + size: common.fontSizeVariants(theme), + colorScheme: { + primary: { + color: theme.colors.$primary500, + '&:hover': { color: theme.colors.$primary400 }, + '&:active': { color: theme.colors.$primary600 }, + }, + danger: { + color: theme.colors.$danger500, + '&:hover': { color: theme.colors.$danger400 }, + '&:active': { color: theme.colors.$danger600 }, + }, + neutral: { + color: theme.colors.$colorTextSecondary, + }, + inherit: { color: 'inherit' }, + }, + }, + defaultVariants: { + colorScheme: 'primary', + variant: 'smallRegular', + }, +})); + +type OwnProps = { isExternal?: boolean; isDisabled?: boolean }; + +// @ts-ignore +export type LinkProps = PrimitiveProps<'a'> & OwnProps & StyleVariants; + +export const Link = (props: LinkProps): JSX.Element => { + const { isExternal, children, href, onClick, ...rest } = props; + + const onClickHandler = onClick + ? (e: React.MouseEvent) => { + if (!href) { + e.preventDefault(); + } + onClick(e); + } + : undefined; + + return ( + + {children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/NotificationBadge.tsx b/packages/clerk-js/src/ui-retheme/primitives/NotificationBadge.tsx new file mode 100644 index 0000000000..265e1e71df --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/NotificationBadge.tsx @@ -0,0 +1,49 @@ +import type { PropsOfComponent, StyleVariants } from '../styledSystem'; +import { common, createCssVariables, createVariants } from '../styledSystem'; +import { Flex } from './Flex'; + +const vars = createCssVariables('accent', 'bg'); + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + color: vars.accent, + backgroundColor: vars.bg, + borderRadius: theme.radii.$sm, + height: theme.space.$4, + minWidth: theme.space.$4, + padding: `${theme.space.$0x5}`, + display: 'inline-flex', + }, + variants: { + textVariant: { ...common.textVariants(theme) }, + colorScheme: { + primary: { + [vars.accent]: theme.colors.$colorTextOnPrimaryBackground, + [vars.bg]: theme.colors.$primary500, + }, + }, + }, + defaultVariants: { + colorScheme: 'primary', + textVariant: 'extraSmallRegular', + }, +})); + +// @ts-ignore +export type NotificationBadgeProps = PropsOfComponent & StyleVariants; + +export const NotificationBadge = (props: NotificationBadgeProps) => { + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/Spinner.tsx b/packages/clerk-js/src/ui-retheme/primitives/Spinner.tsx new file mode 100644 index 0000000000..df9877056f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Spinner.tsx @@ -0,0 +1,69 @@ +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { animations, createCssVariables, createVariants } from '../styledSystem'; + +const { size, thickness, speed } = createCssVariables('speed', 'size', 'thickness'); + +const { applyVariants, filterProps } = createVariants(theme => { + return { + base: { + display: 'inline-block', + borderRadius: '99999px', + borderTop: `${thickness} solid currentColor`, + borderRight: `${thickness} solid currentColor`, + borderBottomWidth: thickness, + borderLeftWidth: thickness, + borderBottomStyle: 'solid', + borderLeftStyle: 'solid', + borderBottomColor: theme.colors.$transparent, + borderLeftColor: theme.colors.$transparent, + opacity: 1, + animation: `${animations.spinning} ${speed} linear 0s infinite normal none running`, + width: [size], + height: [size], + minWidth: [size], + minHeight: [size], + }, + variants: { + colorScheme: { + primary: { borderTopColor: theme.colors.$primary500, borderRightColor: theme.colors.$primary500, opacity: 1 }, + neutral: { + borderTopColor: theme.colors.$blackAlpha700, + borderRightColor: theme.colors.$blackAlpha700, + opacity: 1, + }, + }, + thickness: { + sm: { [thickness]: theme.sizes.$0x5 }, + md: { [thickness]: theme.sizes.$1 }, + }, + size: { + xs: { [size]: theme.sizes.$3 }, + sm: { [size]: theme.sizes.$4 }, + md: { [size]: theme.sizes.$5 }, + lg: { [size]: theme.sizes.$6 }, + xl: { [size]: theme.sizes.$8 }, + }, + speed: { + slow: { [speed]: '600ms' }, + normal: { [speed]: '400ms' }, + }, + }, + defaultVariants: { + speed: 'normal', + thickness: 'sm', + size: 'sm', + }, + }; +}); + +type SpinnerProps = PrimitiveProps<'div'> & StyleVariants; +export const Spinner = (props: SpinnerProps) => { + return ( + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/Table.tsx b/packages/clerk-js/src/ui-retheme/primitives/Table.tsx new file mode 100644 index 0000000000..e7c983a218 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Table.tsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +const { applyVariants, filterProps } = createVariants(theme => { + return { + base: { + borderBottom: theme.borders.$normal, + borderColor: theme.colors.$blackAlpha300, + borderCollapse: 'separate', + borderSpacing: '0', + 'td:not(:first-of-type)': { + paddingLeft: theme.space.$2, + }, + 'th:not(:first-of-type)': { + paddingLeft: theme.space.$2, + }, + 'tr > td': { + paddingBottom: theme.space.$2, + paddingTop: theme.space.$2, + paddingLeft: theme.space.$4, + paddingRight: theme.space.$4, + }, + 'tr > th:first-of-type': { + paddingLeft: theme.space.$5, + }, + 'thead::after': { + content: '""', + display: 'block', + paddingBottom: theme.space.$2, + }, + 'tbody::after': { + content: '""', + display: 'block', + paddingBottom: theme.space.$2, + }, + // border-radius for hover + 'tr > td:first-of-type': { + borderTopLeftRadius: theme.radii.$md, + borderBottomLeftRadius: theme.radii.$md, + }, + 'tr > td:last-of-type': { + borderTopRightRadius: theme.radii.$md, + borderBottomRightRadius: theme.radii.$md, + }, + width: '100%', + }, + variants: {}, + }; +}); + +export type TableProps = PrimitiveProps<'table'> & Omit & StyleVariants; + +export const Table = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Tbody.tsx b/packages/clerk-js/src/ui-retheme/primitives/Tbody.tsx new file mode 100644 index 0000000000..d3503ca0c8 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Tbody.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import type { PrimitiveProps } from '../styledSystem'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +export type TbodyProps = PrimitiveProps<'tbody'> & Omit; + +export const Tbody = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Td.tsx b/packages/clerk-js/src/ui-retheme/primitives/Td.tsx new file mode 100644 index 0000000000..89d08ee9cf --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Td.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + fontSize: theme.fontSizes.$xs, + fontWeight: theme.fontWeights.$normal, + color: theme.colors.$colorText, + }, + variants: {}, +})); + +export type TdProps = PrimitiveProps<'td'> & Omit & StyleVariants; + +export const Td = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Text.tsx b/packages/clerk-js/src/ui-retheme/primitives/Text.tsx new file mode 100644 index 0000000000..95ac5ba598 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Text.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { common, createVariants } from '../styledSystem'; +import { applyDataStateProps } from './applyDataStateProps'; + +const { applyVariants, filterProps } = createVariants(theme => { + return { + base: { + boxSizing: 'border-box', + // TODO: this should probably be inherited + // and handled through cards + color: theme.colors.$colorText, + margin: 0, + fontSize: 'inherit', + ...common.disabled(theme), + }, + variants: { + variant: common.textVariants(theme), + size: common.fontSizeVariants(theme), + colorScheme: { + primary: { color: theme.colors.$colorText }, + onPrimaryBg: { color: theme.colors.$colorTextOnPrimaryBackground }, + danger: { color: theme.colors.$danger500 }, + success: { color: theme.colors.$success500 }, + neutral: { color: theme.colors.$colorTextSecondary }, + inherit: { color: 'inherit' }, + }, + truncate: { + true: { + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + overflow: 'hidden', + }, + }, + }, + defaultVariants: { + variant: 'regularRegular', + }, + }; +}); + +// @ts-ignore +export type TextProps = PrimitiveProps<'p'> & { isDisabled?: boolean } & StyleVariants & { + as?: 'p' | 'div' | 'label' | 'code' | 'span' | 'li' | 'a'; + }; + +export const Text = React.forwardRef((props, ref): JSX.Element => { + const { as: As = 'p', ...rest } = props; + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Th.tsx b/packages/clerk-js/src/ui-retheme/primitives/Th.tsx new file mode 100644 index 0000000000..ffac4ba00a --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Th.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import type { PrimitiveProps, StyleVariants } from '../styledSystem'; +import { createVariants } from '../styledSystem'; +import { colors } from '../utils'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +const { applyVariants, filterProps } = createVariants(theme => ({ + base: { + textAlign: 'left', + fontSize: theme.fontSizes.$xs, + fontWeight: theme.fontWeights.$normal, + color: colors.setAlpha(theme.colors.$colorText, 0.62), + borderBottom: theme.borders.$normal, + borderColor: theme.colors.$blackAlpha300, + paddingBottom: theme.space.$2, + }, + variants: {}, +})); + +export type ThProps = PrimitiveProps<'th'> & Omit & StyleVariants; + +export const Th = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Thead.tsx b/packages/clerk-js/src/ui-retheme/primitives/Thead.tsx new file mode 100644 index 0000000000..988c7d5253 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Thead.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import type { PrimitiveProps } from '../styledSystem'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +export type TheadProps = PrimitiveProps<'thead'> & Omit; + +export const Thead = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/Tr.tsx b/packages/clerk-js/src/ui-retheme/primitives/Tr.tsx new file mode 100644 index 0000000000..ad0fe3be67 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/Tr.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import type { PrimitiveProps } from '../styledSystem'; +import type { BoxProps } from './Box'; +import { Box } from './Box'; + +export type TrProps = PrimitiveProps<'tr'> & Omit; + +export const Tr = React.forwardRef((props, ref) => { + return ( + + ); +}); diff --git a/packages/clerk-js/src/ui-retheme/primitives/applyDataStateProps.ts b/packages/clerk-js/src/ui-retheme/primitives/applyDataStateProps.ts new file mode 100644 index 0000000000..251e926027 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/applyDataStateProps.ts @@ -0,0 +1,13 @@ +import type { StateProps } from '../styledSystem'; + +export const applyDataStateProps = (props: any) => { + const { hasError, isDisabled, isLoading, isOpen, isActive, ...rest } = props as StateProps; + return { + 'data-error': hasError || undefined, + 'data-disabled': isDisabled || undefined, + 'data-loading': isLoading || undefined, + 'data-open': isOpen || undefined, + 'data-active': isActive || undefined, + ...rest, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/hooks/index.ts b/packages/clerk-js/src/ui-retheme/primitives/hooks/index.ts new file mode 100644 index 0000000000..f0f19d1210 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useFormControl'; +export * from './useInput'; diff --git a/packages/clerk-js/src/ui-retheme/primitives/hooks/useFormControl.tsx b/packages/clerk-js/src/ui-retheme/primitives/hooks/useFormControl.tsx new file mode 100644 index 0000000000..98f10eea84 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/hooks/useFormControl.tsx @@ -0,0 +1,207 @@ +import { createContextAndHook } from '@clerk/shared/react'; +import type { ClerkAPIError, FieldId } from '@clerk/types'; +import React from 'react'; + +import type { useFormControl as useFormControlUtil } from '../../utils/useFormControl'; + +/** + * @deprecated + */ +export type FormControlProps = { + /** + * The custom `id` to use for the form control. This is passed directly to the form element (e.g, Input). + * - The form element (e.g. Input) gets the `id` + */ + id: string; + isRequired?: boolean; + hasError?: boolean; + isDisabled?: boolean; + setError: (error: string | ClerkAPIError | undefined) => void; + setSuccess: (message: string) => void; + setWarning: (warning: string) => void; + setInfo: (info: string) => void; + setHasPassedComplexity: (b: boolean) => void; + clearFeedback: () => void; +}; + +/** + * @deprecated + */ +type FormControlContextValue = Required & { errorMessageId: string }; + +/** + * @deprecated Use FormFieldContextProvider + */ +export const [FormControlContext, , useFormControl] = + createContextAndHook('FormControlContext'); + +/** + * @deprecated Use FormFieldContextProvider + */ +export const FormControlContextProvider = (props: React.PropsWithChildren) => { + const { + id: propsId, + isRequired = false, + hasError = false, + isDisabled = false, + setError, + setSuccess, + setWarning, + setInfo, + setHasPassedComplexity, + clearFeedback, + } = props; + // TODO: This shouldnt be targettable + const id = `${propsId}-field`; + /** + * Track whether the `FormErrorText` has been rendered. + * We use this to append its id the `aria-describedby` of the `input`. + */ + const errorMessageId = hasError ? `error-${propsId}` : ''; + const value = React.useMemo( + () => ({ + value: { + isRequired, + hasError, + id, + errorMessageId, + isDisabled, + setError, + setSuccess, + setWarning, + setInfo, + setHasPassedComplexity, + clearFeedback, + }, + }), + [ + isRequired, + hasError, + id, + errorMessageId, + isDisabled, + setError, + setSuccess, + setInfo, + setWarning, + setHasPassedComplexity, + clearFeedback, + ], + ); + return {props.children}; +}; + +type FormFieldProviderProps = ReturnType>['props'] & { + hasError?: boolean; + isDisabled?: boolean; +}; + +type FormFieldContextValue = Omit & { + errorMessageId?: string; + id?: string; + fieldId?: FieldId; +}; +export const [FormFieldContext, useFormField] = createContextAndHook('FormFieldContext'); + +export const FormFieldContextProvider = (props: React.PropsWithChildren) => { + const { + id: propsId, + isRequired = false, + isDisabled = false, + hasError = false, + setError, + setSuccess, + setWarning, + setHasPassedComplexity, + setInfo, + clearFeedback, + children, + ...rest + } = props; + // TODO: This shouldnt be targettable + const id = `${propsId}-field`; + + /** + * Track whether the `FormErrorText` has been rendered. + * We use this to append its id the `aria-describedby` of the `input`. + */ + const errorMessageId = hasError ? `error-${propsId}` : ''; + const value = React.useMemo( + () => ({ + isRequired, + isDisabled, + hasError, + id, + fieldId: propsId, + errorMessageId, + setError, + setSuccess, + setWarning, + setInfo, + clearFeedback, + setHasPassedComplexity, + }), + [ + isRequired, + hasError, + id, + propsId, + errorMessageId, + isDisabled, + setError, + setSuccess, + setWarning, + setInfo, + clearFeedback, + setHasPassedComplexity, + ], + ); + return ( + + {props.children} + + ); +}; + +export const sanitizeInputProps = ( + obj: ReturnType, + keep?: (keyof ReturnType)[], +) => { + const { + radioOptions, + validatePassword, + hasPassedComplexity, + isFocused, + feedback, + feedbackType, + setHasPassedComplexity, + setWarning, + setSuccess, + setError, + setInfo, + errorMessageId, + fieldId, + label, + clearFeedback, + infoText, + ...inputProps + } = obj; + /* eslint-enable */ + + keep?.forEach(key => { + /** + * Ignore error for the index type as we have defined it explicitly above + */ + // @ts-ignore + inputProps[key] = obj[key]; + }); + + return inputProps; +}; diff --git a/packages/clerk-js/src/ui-retheme/primitives/hooks/useInput.ts b/packages/clerk-js/src/ui-retheme/primitives/hooks/useInput.ts new file mode 100644 index 0000000000..b6d2f2eed1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/hooks/useInput.ts @@ -0,0 +1,21 @@ +import type { FormEvent } from 'react'; +import React from 'react'; + +export function useInput(callback: React.FormEventHandler | null | undefined): { + onChange: React.FormEventHandler; + ref: React.MutableRefObject; +} { + const ref = React.useRef(null); + + function onChange(e: FormEvent) { + e.persist(); + if (typeof callback === 'function') { + callback(e); + } + } + + return { + onChange, + ref, + }; +} diff --git a/packages/clerk-js/src/ui-retheme/primitives/index.ts b/packages/clerk-js/src/ui-retheme/primitives/index.ts new file mode 100644 index 0000000000..fa81bf923f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/primitives/index.ts @@ -0,0 +1,29 @@ +export * from './Box'; +export * from './Button'; +export * from './Flex'; +export * from './Grid'; +export * from './Heading'; +export * from './Text'; +export * from './Spinner'; +export * from './Input'; +export * from './Link'; +export * from './Image'; +export * from './Alert'; +export * from './AlertIcon'; +export * from './Input'; +export * from './FormControl'; +export * from './FormErrorText'; +export * from './FormInfoText'; +export * from './FormSuccessText'; +export * from './FormWarningText'; +export * from './FormLabel'; +export * from './Form'; +export * from './Icon'; +export * from './Badge'; +export * from './Table'; +export * from './Thead'; +export * from './Tbody'; +export * from './Tr'; +export * from './Th'; +export * from './Td'; +export * from './NotificationBadge'; diff --git a/packages/clerk-js/src/ui-retheme/router/BaseRouter.tsx b/packages/clerk-js/src/ui-retheme/router/BaseRouter.tsx new file mode 100644 index 0000000000..927a4af444 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/BaseRouter.tsx @@ -0,0 +1,148 @@ +import qs from 'qs'; +import React from 'react'; + +import { getQueryParams, trimTrailingSlash } from '../../utils'; +import { useCoreClerk } from '../contexts'; +import { useWindowEventListener } from '../hooks'; +import { newPaths } from './newPaths'; +import { match } from './pathToRegexp'; +import { Route } from './Route'; +import { RouteContext } from './RouteContext'; + +interface BaseRouterProps { + basePath: string; + startPath: string; + getPath: () => string; + getQueryString: () => string; + internalNavigate: (toURL: URL) => Promise | any; + onExternalNavigate?: () => any; + refreshEvents?: Array; + preservedParams?: string[]; + urlStateParam?: { + startPath: string; + path: string; + componentName: string; + clearUrlStateParam: () => void; + socialProvider: string; + }; + children: React.ReactNode; +} + +export const BaseRouter = ({ + basePath, + startPath, + getPath, + getQueryString, + internalNavigate, + onExternalNavigate, + refreshEvents, + preservedParams, + urlStateParam, + children, +}: BaseRouterProps): JSX.Element => { + const { navigate: externalNavigate } = useCoreClerk(); + + const [routeParts, setRouteParts] = React.useState({ + path: getPath(), + queryString: getQueryString(), + }); + const currentPath = routeParts.path; + const currentQueryString = routeParts.queryString; + const currentQueryParams = getQueryParams(routeParts.queryString); + + const resolve = (to: string): URL => { + return new URL(to, window.location.origin); + }; + + const getMatchData = (path?: string, index?: boolean) => { + const [newIndexPath, newFullPath] = newPaths('', '', path, index); + const currentPathWithoutSlash = trimTrailingSlash(currentPath); + + const matchResult = + (path && match(newFullPath + '/:foo*')(currentPathWithoutSlash)) || + (index && match(newIndexPath)(currentPathWithoutSlash)) || + (index && match(newFullPath)(currentPathWithoutSlash)) || + false; + if (matchResult !== false) { + return matchResult.params; + } else { + return false; + } + }; + + const matches = (path?: string, index?: boolean): boolean => { + return !!getMatchData(path, index); + }; + + const refresh = React.useCallback((): void => { + const newPath = getPath(); + const newQueryString = getQueryString(); + + if (newPath !== currentPath || newQueryString !== currentQueryString) { + setRouteParts({ + path: newPath, + queryString: newQueryString, + }); + } + }, [currentPath, currentQueryString, getPath, getQueryString]); + + useWindowEventListener(refreshEvents, refresh); + + // TODO: Look into the real possible types of globalNavigate + const baseNavigate = async (toURL: URL | undefined): Promise => { + if (!toURL) { + return; + } + + if (toURL.origin !== window.location.origin || !toURL.pathname.startsWith('/' + basePath)) { + if (onExternalNavigate) { + onExternalNavigate(); + } + const res = await externalNavigate(toURL.href); + refresh(); + return res; + } + + // For internal navigation, preserve any query params + // that are marked to be preserved + if (preservedParams) { + const toQueryParams = getQueryParams(toURL.search); + preservedParams.forEach(param => { + if (!toQueryParams[param] && currentQueryParams[param]) { + toQueryParams[param] = currentQueryParams[param]; + } + }); + toURL.search = qs.stringify(toQueryParams); + } + const internalNavRes = await internalNavigate(toURL); + setRouteParts({ path: toURL.pathname, queryString: toURL.search }); + return internalNavRes; + }; + + return ( + { + // + }, + resolve: resolve.bind(this), + refresh: refresh.bind(this), + params: {}, + urlStateParam: urlStateParam, + }} + > + {children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/router/HashRouter.tsx b/packages/clerk-js/src/ui-retheme/router/HashRouter.tsx new file mode 100644 index 0000000000..0e44a73f39 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/HashRouter.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { hasUrlInFragment, stripOrigin } from '../../utils'; +import { BaseRouter } from './BaseRouter'; + +export const hashRouterBase = 'CLERK-ROUTER/HASH'; + +interface HashRouterProps { + preservedParams?: string[]; + children: React.ReactNode; +} + +export const HashRouter = ({ preservedParams, children }: HashRouterProps): JSX.Element => { + const internalNavigate = async (toURL: URL): Promise => { + if (!toURL) { + return; + } + window.location.hash = stripOrigin(toURL).substring(1 + hashRouterBase.length); + return Promise.resolve(); + }; + + const fakeUrl = (): URL => { + // Create a URL object with the contents of the hash + // Use the origin because you can't create a url object without protocol and host + if (hasUrlInFragment(window.location.hash)) { + return new URL(window.location.origin + window.location.hash.substring(1)); + } else { + return new URL(window.location.origin); + } + }; + + const getPath = (): string => { + return fakeUrl().pathname === '/' ? '/' + hashRouterBase : '/' + hashRouterBase + fakeUrl().pathname; + }; + + const getQueryString = (): string => { + return fakeUrl().search; + }; + + return ( + + {children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/router/PathRouter.tsx b/packages/clerk-js/src/ui-retheme/router/PathRouter.tsx new file mode 100644 index 0000000000..bbb951e035 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/PathRouter.tsx @@ -0,0 +1,65 @@ +import React from 'react'; + +import { hasUrlInFragment, mergeFragmentIntoUrl, stripOrigin } from '../../utils'; +import { useCoreClerk } from '../contexts'; +import { BaseRouter } from './BaseRouter'; + +interface PathRouterProps { + basePath: string; + preservedParams?: string[]; + children: React.ReactNode; +} + +export const PathRouter = ({ basePath, preservedParams, children }: PathRouterProps): JSX.Element | null => { + const { navigate } = useCoreClerk(); + const [stripped, setStripped] = React.useState(false); + + if (!navigate) { + throw new Error('Clerk: Missing navigate option.'); + } + + const internalNavigate = (toURL: URL | string | undefined) => { + if (!toURL) { + return; + } + // Only send the path + return navigate(stripOrigin(toURL)); + }; + + const getPath = () => { + return window.location.pathname; + }; + + const getQueryString = () => { + return window.location.search; + }; + + React.useEffect(() => { + const convertHashToPath = async () => { + if (hasUrlInFragment(window.location.hash)) { + const url = mergeFragmentIntoUrl(new URL(window.location.href)); + await internalNavigate(url.href); + setStripped(true); + } + }; + void convertHashToPath(); + }, [setStripped, navigate, window.location.hash]); + + if (hasUrlInFragment(window.location.hash) && !stripped) { + return null; + } + + return ( + + {children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/router/Route.tsx b/packages/clerk-js/src/ui-retheme/router/Route.tsx new file mode 100644 index 0000000000..a4f8a966ef --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/Route.tsx @@ -0,0 +1,124 @@ +import type { LoadedClerk } from '@clerk/types'; +import React from 'react'; + +import { useCoreClerk } from '../../ui/contexts'; +import { pathFromFullPath, trimTrailingSlash } from '../../utils'; +import { useNavigateToFlowStart } from '../hooks'; +import { newPaths } from './newPaths'; +import { match } from './pathToRegexp'; +import { RouteContext, useRouter } from './RouteContext'; + +interface RouteGuardProps { + canActivate: (clerk: LoadedClerk) => boolean; +} + +interface UnguardedRouteProps { + path?: string; + index?: boolean; + flowStart?: boolean; + canActivate?: never; +} +type GuardedRouteProps = { + path?: string; + index?: boolean; + flowStart?: boolean; +} & RouteGuardProps; + +export type RouteProps = React.PropsWithChildren; + +const RouteGuard = ({ canActivate, children }: React.PropsWithChildren): JSX.Element | null => { + const { navigateToFlowStart } = useNavigateToFlowStart(); + const clerk = useCoreClerk(); + + React.useEffect(() => { + if (!canActivate(clerk)) { + void navigateToFlowStart(); + } + }); + if (canActivate(clerk)) { + return <>{children}; + } + return null; +}; + +export function Route(props: RouteProps): JSX.Element | null { + const router = useRouter(); + + if (!props.children) { + return null; + } + + if (!props.index && !props.path) { + return <>{props.children}; + } + + if (!router.matches(props.path, props.index)) { + return null; + } + + const [indexPath, fullPath] = newPaths(router.indexPath, router.fullPath, props.path, props.index); + + const resolve = (to: string) => { + const url = new URL(to, window.location.origin + fullPath + '/'); + url.pathname = trimTrailingSlash(url.pathname); + return url; + }; + + const newGetMatchData = (path?: string, index?: boolean) => { + const [newIndexPath, newFullPath] = newPaths(indexPath, fullPath, path, index); + const currentPath = trimTrailingSlash(router.currentPath); + const matchResult = + (path && match(newFullPath + '/:foo*')(currentPath)) || + (index && match(newIndexPath)(currentPath)) || + (index && match(newFullPath)(currentPath)) || + false; + if (matchResult !== false) { + return matchResult.params; + } else { + return false; + } + }; + + const rawParams = router.getMatchData(props.path, props.index) || {}; + const paramsDict: Record = {}; + for (const [key, value] of Object.entries(rawParams)) { + paramsDict[key] = value; + } + + const flowStartPath = + (props.flowStart + ? //set it as the old full path (the previous step), + //replacing the base path for navigateToFlowStart() to work as expected + pathFromFullPath(router.fullPath).replace('/' + router.basePath, '') + : router.flowStartPath) || router.startPath; + + return ( + { + return newGetMatchData(path, index) ? true : false; + }, + resolve: resolve, + navigate: (to: string) => { + const toURL = resolve(to); + return router.baseNavigate(toURL); + }, + refresh: router.refresh, + params: paramsDict, + urlStateParam: router.urlStateParam, + }} + > + {props.canActivate ? {props.children} : props.children} + + ); +} diff --git a/packages/clerk-js/src/ui-retheme/router/RouteContext.tsx b/packages/clerk-js/src/ui-retheme/router/RouteContext.tsx new file mode 100644 index 0000000000..370e776ac9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/RouteContext.tsx @@ -0,0 +1,40 @@ +import type { ParsedQs } from 'qs'; +import React from 'react'; + +export interface RouteContextValue { + basePath: string; + startPath: string; + flowStartPath: string; + fullPath: string; + indexPath: string; + currentPath: string; + matches: (path?: string, index?: boolean) => boolean; + baseNavigate: (toURL: URL) => Promise; + navigate: (to: string) => Promise; + resolve: (to: string) => URL; + refresh: () => void; + params: { [key: string]: string }; + queryString: string; + queryParams: ParsedQs; + preservedParams?: string[]; + getMatchData: (path?: string, index?: boolean) => false | object; + urlStateParam?: { + startPath: string; + path: string; + componentName: string; + clearUrlStateParam: () => void; + socialProvider: string; + }; +} + +export const RouteContext = React.createContext(null); + +RouteContext.displayName = 'RouteContext'; + +export const useRouter = (): RouteContextValue => { + const ctx = React.useContext(RouteContext); + if (!ctx) { + throw new Error('useRouter called while Router is null'); + } + return ctx; +}; diff --git a/packages/clerk-js/src/ui-retheme/router/Switch.tsx b/packages/clerk-js/src/ui-retheme/router/Switch.tsx new file mode 100644 index 0000000000..04a7b57839 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/Switch.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +import type { RouteProps } from './Route'; +import { Route } from './Route'; +import { useRouter } from './RouteContext'; + +function assertRoute(v: any): v is React.ReactElement { + return !!v && React.isValidElement(v) && typeof v === 'object' && (v as React.ReactElement).type === Route; +} + +export function Switch({ children }: { children: React.ReactNode }): JSX.Element { + const router = useRouter(); + + let node: React.ReactNode = null; + React.Children.forEach(children, child => { + if (node || !assertRoute(child)) { + return; + } + + const { index, path } = child.props; + if ((!index && !path) || router.matches(path, index)) { + node = child; + } + }); + + return <>{node}; +} diff --git a/packages/clerk-js/src/ui-retheme/router/VirtualRouter.tsx b/packages/clerk-js/src/ui-retheme/router/VirtualRouter.tsx new file mode 100644 index 0000000000..a589a33c3c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/VirtualRouter.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { useClerkModalStateParams } from '../hooks'; +import { BaseRouter } from './BaseRouter'; +export const VIRTUAL_ROUTER_BASE_PATH = 'CLERK-ROUTER/VIRTUAL'; + +interface VirtualRouterProps { + startPath: string; + preservedParams?: string[]; + onExternalNavigate?: () => any; + children: React.ReactNode; +} + +export const VirtualRouter = ({ + startPath, + preservedParams, + onExternalNavigate, + children, +}: VirtualRouterProps): JSX.Element => { + const [currentURL, setCurrentURL] = React.useState( + new URL('/' + VIRTUAL_ROUTER_BASE_PATH + startPath, window.location.origin), + ); + const { urlStateParam, removeQueryParam } = useClerkModalStateParams(); + + if (urlStateParam.componentName) { + removeQueryParam(); + } + + const internalNavigate = (toURL: URL | undefined) => { + if (!toURL) { + return; + } + setCurrentURL(toURL); + }; + + const getPath = () => currentURL.pathname; + + const getQueryString = () => currentURL.search; + + return ( + + {children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/router/__mocks__/RouteContext.tsx b/packages/clerk-js/src/ui-retheme/router/__mocks__/RouteContext.tsx new file mode 100644 index 0000000000..cabc7dfec9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/__mocks__/RouteContext.tsx @@ -0,0 +1,11 @@ +import { noop } from '@clerk/shared'; + +export const useRouter = () => ({ + resolve: jest.fn(() => ({ + toURL: { + href: 'http://test.host/test-href', + }, + })), + matches: jest.fn(noop), + navigate: jest.fn(noop), +}); diff --git a/packages/clerk-js/src/ui-retheme/router/__tests__/HashRouter.test.tsx b/packages/clerk-js/src/ui-retheme/router/__tests__/HashRouter.test.tsx new file mode 100644 index 0000000000..a1d79bd9df --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/__tests__/HashRouter.test.tsx @@ -0,0 +1,109 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import type Clerk from '../../../core/clerk'; +import { HashRouter, Route, useRouter } from '..'; + +const mockNavigate = jest.fn(); + +jest.mock('ui/contexts', () => { + return { + useCoreClerk: () => { + return { + navigate: (to: string) => { + mockNavigate(to); + if (to) { + // @ts-ignore + window.location = new URL(to, window.location.origin); + } + return Promise.resolve(); + }, + } as Clerk; + }, + }; +}); + +const Button = ({ to, children }: React.PropsWithChildren<{ to: string }>) => { + const router = useRouter(); + return ( + + ); +}; + +const Tester = () => ( + + +

Index
+ + + + +
Bar
+
+ +); + +describe('HashRouter', () => { + const oldWindowLocation = window.location; + + beforeAll(() => { + // @ts-ignore + delete window.location; + }); + + afterAll(() => { + window.location = oldWindowLocation; + }); + + beforeEach(() => { + mockNavigate.mockReset(); + }); + + describe('when hash has a path included in it', () => { + beforeEach(() => { + // @ts-ignore + window.location = new URL('https://www.example.com/hash#/foo'); + }); + + it('loads that path', () => { + // Wrap this is an await because we need a state update with the path change + render(); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(screen.queryByText('Bar')).toBeInTheDocument(); + }); + }); + + describe('when query has a preservedParam', () => { + beforeEach(() => { + // @ts-ignore + window.location = new URL('https://www.example.com/hash#/?preserved=1'); + }); + + it('preserves the param for internal navigation', async () => { + render(); + + const button = screen.getByRole('button', { name: /Internal/i }); + await userEvent.click(button); + + expect(window.location.hash).toBe('#/foo?preserved=1'); + expect(screen.queryByText('Bar')).toBeInTheDocument(); + }); + + it('removes the param for external navigation', async () => { + render(); + + const button = screen.getByRole('button', { name: /External/i }); + await userEvent.click(button); + + expect(mockNavigate).toHaveBeenNthCalledWith(1, 'https://www.example.com/external'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/router/__tests__/PathRouter.test.tsx b/packages/clerk-js/src/ui-retheme/router/__tests__/PathRouter.test.tsx new file mode 100644 index 0000000000..f74ef75d03 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/__tests__/PathRouter.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import type Clerk from '../../../core/clerk'; +import { PathRouter, Route, useRouter } from '..'; + +const mockNavigate = jest.fn(); + +jest.mock('ui/contexts', () => { + return { + useCoreClerk: () => { + return { + navigate: (to: string) => { + mockNavigate(to); + if (to) { + // @ts-ignore + window.location = new URL(to, window.location.origin); + } + return Promise.resolve(); + }, + } as Clerk; + }, + }; +}); + +const Button = ({ to, children }: React.PropsWithChildren<{ to: string }>) => { + const router = useRouter(); + return ( + + ); +}; + +const Tester = () => ( + + + + + + +); + +describe('PathRouter', () => { + const oldWindowLocation = window.location; + + beforeAll(() => { + // @ts-ignore + delete window.location; + }); + + afterAll(() => { + window.location = oldWindowLocation; + }); + + beforeEach(() => { + mockNavigate.mockReset(); + }); + + describe('when hash has a path included in it', () => { + beforeEach(() => { + // @ts-ignore + window.location = new URL('https://www.example.com/foo#/bar'); + }); + + it('adds the hash path to the primary path', async () => { + render(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenNthCalledWith(1, '/foo/bar'); + }); + }); + }); + + describe('when query has a preservedParam', () => { + beforeEach(() => { + // @ts-ignore + window.location = new URL('https://www.example.com/foo/bar?preserved=1'); + }); + + it('preserves the param for internal navigation', async () => { + render(); + + const button = screen.getByRole('button', { name: /Internal/i }); + await userEvent.click(button); + + expect(mockNavigate).toHaveBeenNthCalledWith(1, '/foo/baz?preserved=1'); + }); + + it('removes the param for external navigation', async () => { + render(); + + const button = screen.getByRole('button', { name: /External/i }); + await userEvent.click(button); + + expect(mockNavigate).toHaveBeenNthCalledWith(1, 'https://www.example.com/'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/router/__tests__/Switch.test.tsx b/packages/clerk-js/src/ui-retheme/router/__tests__/Switch.test.tsx new file mode 100644 index 0000000000..ea06285395 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/__tests__/Switch.test.tsx @@ -0,0 +1,134 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { HashRouter, Route, Switch } from '../../router'; + +const mockNavigate = jest.fn(); + +jest.mock('ui/contexts', () => ({ + useCoreClerk: () => ({ + navigate: jest.fn(to => { + mockNavigate(to); + if (to) { + // @ts-ignore + window.location = new URL(to, window.location.origin); + } + return Promise.resolve(); + }), + }), +})); + +const oldWindowLocation = window.location; +const setWindowOrigin = (origin: string) => { + // @ts-ignore + delete window.location; + // the URL interface is very similar to window.location + // we use it to easily mock the location methods in tests + (window.location as any) = new URL(origin); +}; + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + window.location = oldWindowLocation; + }); + + it('ignores nodes that are not of type Route', () => { + setWindowOrigin('http://dashboard.example.com/#/first'); + render( + + + hey + first + + , + ); + + expect(screen.queryByText('hey')).toBeNull(); + expect(screen.queryByText('first 1')).toBeDefined(); + }); + + it('renders only the first Route that matches', () => { + setWindowOrigin('http://dashboard.example.com/#/first'); + render( + + + first 1 + first 2 + catchall + + , + ); + + expect(screen.queryByText('first 1')).toBeDefined(); + expect(screen.queryByText('first 2')).toBeNull(); + expect(screen.queryByText('catchall')).toBeNull(); + }); + + it('renders null if no route matches', () => { + setWindowOrigin('http://dashboard.example.com/#/cat'); + render( + + + first + second + third + + , + ); + + expect(screen.queryByText('first')).toBeNull(); + expect(screen.queryByText('second')).toBeNull(); + expect(screen.queryByText('third')).toBeNull(); + }); + + it('always matches a Route without path', () => { + setWindowOrigin('http://dashboard.example.com/#/cat'); + render( + + + first 1 + first 2 + catchall + + , + ); + + expect(screen.queryByText('first 1')).toBeNull(); + expect(screen.queryByText('first 2')).toBeNull(); + expect(screen.queryByText('catchall')).toBeDefined(); + }); + + it('always matches a Route without path even when other routes match down the tree', () => { + setWindowOrigin('http://dashboard.example.com/#/first'); + render( + + + catchall + first + + , + ); + + expect(screen.queryByText('catchall')).toBeDefined(); + expect(screen.queryByText('firs')).toBeNull(); + }); + + it('always matches a Route without path even if its an index route', () => { + setWindowOrigin('http://dashboard.example.com/#/first'); + render( + + + catchall + first + + , + ); + + expect(screen.queryByText('catchall')).toBeDefined(); + expect(screen.queryByText('firs')).toBeNull(); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/router/__tests__/VirtualRouter.test.tsx b/packages/clerk-js/src/ui-retheme/router/__tests__/VirtualRouter.test.tsx new file mode 100644 index 0000000000..93d75e57a1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/__tests__/VirtualRouter.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { Route, useRouter, VirtualRouter } from '..'; + +const mockNavigate = jest.fn(); + +jest.mock('ui/contexts', () => ({ + useCoreClerk: () => ({ + navigate: jest.fn(to => { + mockNavigate(to); + if (to) { + // @ts-ignore + window.location = new URL(to, window.location.origin); + } + return Promise.resolve(); + }), + }), +})); + +const Button = ({ to, children }: React.PropsWithChildren<{ to: string }>) => { + const router = useRouter(); + return ( + + ); +}; +const ShowPreserved = () => { + const { queryParams } = useRouter(); + return
{`preserved=${queryParams.preserved}`}
; +}; +const Tester = () => ( + + +
Index
+ +
+ +
Created
+ +
+ + + +
+); + +describe('VirtualRouter', () => { + const oldWindowLocation = window.location; + + beforeAll(() => { + // @ts-ignore + delete window.location; + }); + + afterAll(() => { + window.location = oldWindowLocation; + }); + + beforeEach(() => { + mockNavigate.mockReset(); + }); + + describe('when mounted', () => { + beforeEach(() => { + // @ts-ignore + window.location = new URL('https://www.example.com/virtual'); + }); + + it('it loads the start path', async () => { + render(); + expect(screen.queryByText('Index')).toBeInTheDocument(); + }); + }); + + describe('when a preserved query param is created internally', () => { + beforeEach(() => { + // @ts-ignore + window.location = new URL('https://www.example.com/virtual'); + }); + + it('preserves the param for internal navigation', async () => { + render(); + const createButton = screen.getByRole('button', { name: /Create/i }); + expect(createButton).toBeInTheDocument(); + await userEvent.click(createButton); + const preserveButton = screen.getByText(/Preserve/i); + expect(preserveButton).toBeInTheDocument(); + await userEvent.click(preserveButton); + expect(screen.queryByText('preserved=1')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/router/index.tsx b/packages/clerk-js/src/ui-retheme/router/index.tsx new file mode 100644 index 0000000000..aab3a4c0b5 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/index.tsx @@ -0,0 +1,8 @@ +export * from './RouteContext'; +export * from './HashRouter'; +export * from './PathRouter'; +export * from './VirtualRouter'; +export * from './Route'; +export * from './Switch'; + +export type { ParsedQs } from 'qs'; diff --git a/packages/clerk-js/src/ui-retheme/router/newPaths.ts b/packages/clerk-js/src/ui-retheme/router/newPaths.ts new file mode 100644 index 0000000000..9419af1198 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/newPaths.ts @@ -0,0 +1,18 @@ +export const newPaths = (oldIndexPath: string, oldFullPath: string, path?: string, index?: boolean) => { + let indexPath = oldIndexPath; + if (path) { + indexPath = oldFullPath; + if (!index) { + indexPath += '/' + path; + } + } + if (indexPath.startsWith('//')) { + indexPath = indexPath.substr(1); + } + + let fullPath = oldFullPath + (path ? '/' + path : ''); + if (fullPath.startsWith('//')) { + fullPath = fullPath.substr(1); + } + return [indexPath, fullPath]; +}; diff --git a/packages/clerk-js/src/ui-retheme/router/pathToRegexp.ts b/packages/clerk-js/src/ui-retheme/router/pathToRegexp.ts new file mode 100644 index 0000000000..1e69a23e3a --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/router/pathToRegexp.ts @@ -0,0 +1,611 @@ +/** + * Tokenizer results. + */ +interface LexToken { + type: 'OPEN' | 'CLOSE' | 'PATTERN' | 'NAME' | 'CHAR' | 'ESCAPED_CHAR' | 'MODIFIER' | 'END'; + index: number; + value: string; +} + +/** + * Tokenize input string. + */ +function lexer(str: string): LexToken[] { + const tokens: LexToken[] = []; + let i = 0; + + while (i < str.length) { + const char = str[i]; + + if (char === '*' || char === '+' || char === '?') { + tokens.push({ type: 'MODIFIER', index: i, value: str[i++] }); + continue; + } + + if (char === '\\') { + tokens.push({ type: 'ESCAPED_CHAR', index: i++, value: str[i++] }); + continue; + } + + if (char === '{') { + tokens.push({ type: 'OPEN', index: i, value: str[i++] }); + continue; + } + + if (char === '}') { + tokens.push({ type: 'CLOSE', index: i, value: str[i++] }); + continue; + } + + if (char === ':') { + let name = ''; + let j = i + 1; + + while (j < str.length) { + const code = str.charCodeAt(j); + + if ( + // `0-9` + (code >= 48 && code <= 57) || + // `A-Z` + (code >= 65 && code <= 90) || + // `a-z` + (code >= 97 && code <= 122) || + // `_` + code === 95 + ) { + name += str[j++]; + continue; + } + + break; + } + + if (!name) { + throw new TypeError(`Missing parameter name at ${i}`); + } + + tokens.push({ type: 'NAME', index: i, value: name }); + i = j; + continue; + } + + if (char === '(') { + let count = 1; + let pattern = ''; + let j = i + 1; + + if (str[j] === '?') { + throw new TypeError(`Pattern cannot start with "?" at ${j}`); + } + + while (j < str.length) { + if (str[j] === '\\') { + pattern += str[j++] + str[j++]; + continue; + } + + if (str[j] === ')') { + count--; + if (count === 0) { + j++; + break; + } + } else if (str[j] === '(') { + count++; + if (str[j + 1] !== '?') { + throw new TypeError(`Capturing groups are not allowed at ${j}`); + } + } + + pattern += str[j++]; + } + + if (count) { + throw new TypeError(`Unbalanced pattern at ${i}`); + } + if (!pattern) { + throw new TypeError(`Missing pattern at ${i}`); + } + + tokens.push({ type: 'PATTERN', index: i, value: pattern }); + i = j; + continue; + } + + tokens.push({ type: 'CHAR', index: i, value: str[i++] }); + } + + tokens.push({ type: 'END', index: i, value: '' }); + + return tokens; +} + +export interface ParseOptions { + /** + * Set the default delimiter for repeat parameters. (default: `'/'`) + */ + delimiter?: string; + /** + * List of characters to automatically consider prefixes when parsing. + */ + prefixes?: string; +} + +/** + * Parse a string for the raw tokens. + */ +export function parse(str: string, options: ParseOptions = {}): Token[] { + const tokens = lexer(str); + const { prefixes = './' } = options; + const defaultPattern = `[^${escapeString(options.delimiter || '/#?')}]+?`; + const result: Token[] = []; + let key = 0; + let i = 0; + let path = ''; + + const tryConsume = (type: LexToken['type']): string | undefined => { + if (i < tokens.length && tokens[i].type === type) { + return tokens[i++].value; + } + return undefined; + }; + + const mustConsume = (type: LexToken['type']): string => { + const value = tryConsume(type); + if (value !== undefined) { + return value; + } + const { type: nextType, index } = tokens[i]; + throw new TypeError(`Unexpected ${nextType} at ${index}, expected ${type}`); + }; + + const consumeText = (): string => { + let result = ''; + let value: string | undefined; + // tslint:disable-next-line + while ((value = tryConsume('CHAR') || tryConsume('ESCAPED_CHAR'))) { + result += value; + } + return result; + }; + + while (i < tokens.length) { + const char = tryConsume('CHAR'); + const name = tryConsume('NAME'); + const pattern = tryConsume('PATTERN'); + + if (name || pattern) { + let prefix = char || ''; + + if (prefixes.indexOf(prefix) === -1) { + path += prefix; + prefix = ''; + } + + if (path) { + result.push(path); + path = ''; + } + + result.push({ + name: name || key++, + prefix, + suffix: '', + pattern: pattern || defaultPattern, + modifier: tryConsume('MODIFIER') || '', + }); + continue; + } + + const value = char || tryConsume('ESCAPED_CHAR'); + if (value) { + path += value; + continue; + } + + if (path) { + result.push(path); + path = ''; + } + + const open = tryConsume('OPEN'); + if (open) { + const prefix = consumeText(); + const name = tryConsume('NAME') || ''; + const pattern = tryConsume('PATTERN') || ''; + const suffix = consumeText(); + + mustConsume('CLOSE'); + + result.push({ + name: name || (pattern ? key++ : ''), + pattern: name && !pattern ? defaultPattern : pattern, + prefix, + suffix, + modifier: tryConsume('MODIFIER') || '', + }); + continue; + } + + mustConsume('END'); + } + + return result; +} + +export interface TokensToFunctionOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * Function for encoding input strings for output. + */ + encode?: (value: string, token: Key) => string; + /** + * When `false` the function can produce an invalid (unmatched) path. (default: `true`) + */ + validate?: boolean; +} + +/** + * Compile a string to a template function for the path. + */ +export function compile

(str: string, options?: ParseOptions & TokensToFunctionOptions) { + return tokensToFunction

(parse(str, options), options); +} + +export type PathFunction

= (data?: P) => string; + +/** + * Expose a method for transforming tokens into the path function. + */ +export function tokensToFunction

( + tokens: Token[], + options: TokensToFunctionOptions = {}, +): PathFunction

{ + const reFlags = flags(options); + const { encode = (x: string) => x, validate = true } = options; + + // Compile all the tokens into regexps. + const matches = tokens.map(token => { + if (typeof token === 'object') { + return new RegExp(`^(?:${token.pattern})$`, reFlags); + } + return undefined; + }); + + return (data: Record | null | undefined) => { + let path = ''; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (typeof token === 'string') { + path += token; + continue; + } + + const value = data ? data[token.name] : undefined; + const optional = token.modifier === '?' || token.modifier === '*'; + const repeat = token.modifier === '*' || token.modifier === '+'; + + if (Array.isArray(value)) { + if (!repeat) { + throw new TypeError(`Expected "${token.name}" to not repeat, but got an array`); + } + + if (value.length === 0) { + if (optional) { + continue; + } + + throw new TypeError(`Expected "${token.name}" to not be empty`); + } + + for (let j = 0; j < value.length; j++) { + const segment = encode(value[j], token); + + if (validate && !(matches[i] as RegExp).test(segment)) { + throw new TypeError(`Expected all "${token.name}" to match "${token.pattern}", but got "${segment}"`); + } + + path += token.prefix + segment + token.suffix; + } + + continue; + } + + if (typeof value === 'string' || typeof value === 'number') { + const segment = encode(String(value), token); + + if (validate && !(matches[i] as RegExp).test(segment)) { + throw new TypeError(`Expected "${token.name}" to match "${token.pattern}", but got "${segment}"`); + } + + path += token.prefix + segment + token.suffix; + continue; + } + + if (optional) { + continue; + } + + const typeOfMessage = repeat ? 'an array' : 'a string'; + throw new TypeError(`Expected "${token.name}" to be ${typeOfMessage}`); + } + + return path; + }; +} + +export interface RegexpToFunctionOptions { + /** + * Function for decoding strings for params. + */ + decode?: (value: string, token: Key) => string; +} + +/** + * A match result contains data about the path match. + */ +export interface MatchResult

{ + path: string; + index: number; + params: P; +} + +/** + * A match is either `false` (no match) or a match result. + */ +export type Match

= false | MatchResult

; + +/** + * The match function takes a string and returns whether it matched the path. + */ +export type MatchFunction

= (path: string) => Match

; + +/** + * Create path match function from `path-to-regexp` spec. + */ +export function match

( + str: Path, + options?: ParseOptions & TokensToRegexpOptions & RegexpToFunctionOptions, +) { + const keys: Key[] = []; + const re = pathToRegexp(str, keys, options); + return regexpToFunction

(re, keys, options); +} + +/** + * Create a path match function from `path-to-regexp` output. + */ +export function regexpToFunction

( + re: RegExp, + keys: Key[], + options: RegexpToFunctionOptions = {}, +): MatchFunction

{ + const { decode = (x: string) => x } = options; + + return function (pathname: string) { + const m = re.exec(pathname); + if (!m) { + return false; + } + + const { 0: path, index } = m; + const params = Object.create(null); + + for (let i = 1; i < m.length; i++) { + // tslint:disable-next-line + if (m[i] === undefined) { + continue; + } + + const key = keys[i - 1]; + + if (key.modifier === '*' || key.modifier === '+') { + params[key.name] = m[i].split(key.prefix + key.suffix).map(value => { + return decode(value, key); + }); + } else { + params[key.name] = decode(m[i], key); + } + } + + return { path, index, params }; + }; +} + +/** + * Escape a regular expression string. + */ +function escapeString(str: string) { + return str.replace(/([.+*?=^!:${}()[\]|/\\])/g, '\\$1'); +} + +/** + * Get the flags for a regexp from the options. + */ +function flags(options?: { sensitive?: boolean }) { + return options && options.sensitive ? '' : 'i'; +} + +/** + * Metadata about a key. + */ +export interface Key { + name: string | number; + prefix: string; + suffix: string; + pattern: string; + modifier: string; +} + +/** + * A token is a string (nothing special) or key metadata (capture group). + */ +export type Token = string | Key; + +/** + * Pull out keys from a regexp. + */ +function regexpToRegexp(path: RegExp, keys?: Key[]): RegExp { + if (!keys) { + return path; + } + + // Use a negative lookahead to match only capturing groups. + const groups = path.source.match(/\((?!\?)/g); + + if (groups) { + for (let i = 0; i < groups.length; i++) { + keys.push({ + name: i, + prefix: '', + suffix: '', + modifier: '', + pattern: '', + }); + } + } + + return path; +} + +/** + * Transform an array into a regexp. + */ +function arrayToRegexp( + paths: Array, + keys?: Key[], + options?: TokensToRegexpOptions & ParseOptions, +): RegExp { + const parts = paths.map(path => pathToRegexp(path, keys, options).source); + return new RegExp(`(?:${parts.join('|')})`, flags(options)); +} + +/** + * Create a path regexp from string input. + */ +function stringToRegexp(path: string, keys?: Key[], options?: TokensToRegexpOptions & ParseOptions) { + return tokensToRegexp(parse(path, options), keys, options); +} + +export interface TokensToRegexpOptions { + /** + * When `true` the regexp will be case sensitive. (default: `false`) + */ + sensitive?: boolean; + /** + * When `true` the regexp allows an optional trailing delimiter to match. (default: `false`) + */ + strict?: boolean; + /** + * When `true` the regexp will match to the end of the string. (default: `true`) + */ + end?: boolean; + /** + * When `true` the regexp will match from the beginning of the string. (default: `true`) + */ + start?: boolean; + /** + * Sets the final character for non-ending optimistic matches. (default: `/`) + */ + delimiter?: string; + /** + * List of characters that can also be "end" characters. + */ + endsWith?: string; + /** + * Encode path tokens for use in the `RegExp`. + */ + encode?: (value: string) => string; +} + +/** + * Expose a function for taking tokens and returning a RegExp. + */ +export function tokensToRegexp(tokens: Token[], keys?: Key[], options: TokensToRegexpOptions = {}) { + const { strict = false, start = true, end = true, encode = (x: string) => x } = options; + const endsWith = `[${escapeString(options.endsWith || '')}]|$`; + const delimiter = `[${escapeString(options.delimiter || '/#?')}]`; + let route = start ? '^' : ''; + + // Iterate over the tokens and create our regexp string. + for (const token of tokens) { + if (typeof token === 'string') { + route += escapeString(encode(token)); + } else { + const prefix = escapeString(encode(token.prefix)); + const suffix = escapeString(encode(token.suffix)); + + if (token.pattern) { + if (keys) { + keys.push(token); + } + + if (prefix || suffix) { + if (token.modifier === '+' || token.modifier === '*') { + const mod = token.modifier === '*' ? '?' : ''; + route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${mod}`; + } else { + route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`; + } + } else { + route += `(${token.pattern})${token.modifier}`; + } + } else { + route += `(?:${prefix}${suffix})${token.modifier}`; + } + } + } + + if (end) { + if (!strict) { + route += `${delimiter}?`; + } + + route += !options.endsWith ? '$' : `(?=${endsWith})`; + } else { + const endToken = tokens[tokens.length - 1]; + const isEndDelimited = + typeof endToken === 'string' + ? delimiter.indexOf(endToken[endToken.length - 1]) > -1 + : // tslint:disable-next-line + endToken === undefined; + + if (!strict) { + route += `(?:${delimiter}(?=${endsWith}))?`; + } + + if (!isEndDelimited) { + route += `(?=${delimiter}|${endsWith})`; + } + } + + return new RegExp(route, flags(options)); +} + +/** + * Supported `path-to-regexp` input types. + */ +export type Path = string | RegExp | Array; + +/** + * Normalize the given path string, returning a regular expression. + * + * An empty array can be passed in for the keys, which will hold the + * placeholder key descriptions. For example, using `/user/:id`, `keys` will + * contain `[{ name: 'id', delimiter: '/', optional: false, repeat: false }]`. + */ +export function pathToRegexp(path: Path, keys?: Key[], options?: TokensToRegexpOptions & ParseOptions) { + if (path instanceof RegExp) { + return regexpToRegexp(path, keys); + } + if (Array.isArray(path)) { + return arrayToRegexp(path, keys, options); + } + return stringToRegexp(path, keys, options); +} diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/InternalThemeProvider.tsx b/packages/clerk-js/src/ui-retheme/styledSystem/InternalThemeProvider.tsx new file mode 100644 index 0000000000..240869dc96 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/InternalThemeProvider.tsx @@ -0,0 +1,30 @@ +// eslint-disable-next-line no-restricted-imports +import createCache from '@emotion/cache'; +// eslint-disable-next-line no-restricted-imports +import { CacheProvider, ThemeProvider } from '@emotion/react'; +import React from 'react'; + +import { useAppearance } from '../customizables'; +import type { InternalTheme } from './index'; + +const el = document.querySelector('style#cl-style-insertion-point'); + +const cache = createCache({ + key: 'cl-internal', + prepend: !el, + insertionPoint: el ? (el as HTMLElement) : undefined, +}); + +type InternalThemeProviderProps = React.PropsWithChildren<{ + theme?: InternalTheme; +}>; + +export const InternalThemeProvider = (props: InternalThemeProviderProps) => { + const { parsedInternalTheme } = useAppearance(); + + return ( + + {props.children} + + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/__tests__/createVariants.test.ts b/packages/clerk-js/src/ui-retheme/styledSystem/__tests__/createVariants.test.ts new file mode 100644 index 0000000000..35858f4ed0 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/__tests__/createVariants.test.ts @@ -0,0 +1,330 @@ +import { createCssVariables } from '../createCssVariables'; +import { createVariants } from '../createVariants'; + +const baseTheme = { + fontSizes: { + sm: 'sm', + md: 'md', + }, + colors: { + primary500: 'primary500', + success500: 'success500', + }, + radii: { + full: 'full', + none: 'none', + }, +}; + +describe('createVariants', () => { + it('applies base styles', () => { + const { applyVariants } = createVariants(theme => ({ + base: { + backgroundColor: theme.colors.primary500, + }, + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + }, + })); + + const res = applyVariants({ size: 'small' })(baseTheme); + expect(res).toEqual({ fontSize: baseTheme.fontSizes.sm, backgroundColor: baseTheme.colors.primary500 }); + }); + + it('merges nested pseudo-selector objecst', () => { + const { applyVariants } = createVariants(theme => ({ + base: { + backgroundColor: theme.colors.primary500, + '&:active': { + backgroundColor: theme.colors.success500, + }, + }, + variants: { + size: { + small: { + fontSize: theme.fontSizes.sm, + '&:active': { + transform: 'scale(0.98)', + }, + }, + medium: { fontSize: theme.fontSizes.md }, + }, + }, + })); + + const res = applyVariants({ size: 'small' })(baseTheme); + expect(res).toEqual({ + fontSize: baseTheme.fontSizes.sm, + backgroundColor: baseTheme.colors.primary500, + '&:active': { + backgroundColor: baseTheme.colors.success500, + transform: 'scale(0.98)', + }, + }); + }); + + it('supports termplate literals with references to the theme prop', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: `${theme.fontSizes.sm}` }, + }, + }, + })); + + const res = applyVariants({ size: 'small' })(baseTheme); + expect(res).toEqual({ fontSize: baseTheme.fontSizes.sm }); + }); + + it('supports template literals with references to the theme prop', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: `${theme.fontSizes.sm}` }, + }, + }, + })); + + const res = applyVariants({ size: 'small' })(baseTheme); + expect(res).toEqual({ fontSize: baseTheme.fontSizes.sm }); + }); + + it('respects variant specificity - the variant that comes last wins', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + type: { + subtitle: { color: theme.colors.success500, fontSize: theme.fontSizes.sm }, + }, + size: { + small: { fontSize: `${theme.fontSizes.sm}` }, + md: { fontSize: `${theme.fontSizes.md}` }, + }, + }, + })); + + const res = applyVariants({ type: 'subtitle', size: 'md' })(baseTheme); + expect(res).toEqual({ color: baseTheme.colors.success500, fontSize: baseTheme.fontSizes.md }); + }); + + it('applies variants based on props', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + color: { + blue: { backgroundColor: theme.colors.primary500 }, + green: { backgroundColor: theme.colors.success500 }, + }, + }, + })); + + const res = applyVariants({ size: 'small' })(baseTheme); + expect(res).toEqual({ fontSize: baseTheme.fontSizes.sm }); + }); + + it('applies boolean-based variants based on props', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + rounded: { + true: { + borderRadius: theme.radii.full, + }, + }, + }, + })); + + // @ts-ignore + const res = applyVariants({ size: 'small', rounded: true })(baseTheme); + expect(res).toEqual({ fontSize: baseTheme.fontSizes.sm, borderRadius: 'full' }); + }); + + it('applies boolean-based variants based on default variants', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + rounded: { + true: { + borderRadius: theme.radii.full, + }, + }, + }, + defaultVariants: { + // @ts-expect-error + rounded: true, + size: 'small', + }, + })); + + const res = applyVariants()(baseTheme); + expect(res).toEqual({ fontSize: baseTheme.fontSizes.sm, borderRadius: 'full' }); + }); + + it('applies falsy boolean-based variants', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + rounded: { + false: { + borderRadius: theme.radii.none, + }, + }, + }, + defaultVariants: { + // @ts-expect-error + rounded: false, + }, + })); + + const res = applyVariants()(baseTheme); + expect(res).toEqual({ borderRadius: baseTheme.radii.none }); + }); + + it('applies variants based on props and default variants if found', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + color: { + blue: { backgroundColor: theme.colors.primary500 }, + green: { backgroundColor: theme.colors.success500 }, + }, + }, + defaultVariants: { + color: 'blue', + }, + })); + + const res = applyVariants({ size: 'small' })(baseTheme); + expect(res).toEqual({ + fontSize: baseTheme.fontSizes.sm, + backgroundColor: 'primary500', + }); + }); + + it('applies rules from compound variants', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + color: { + blue: { backgroundColor: theme.colors.primary500 }, + green: { backgroundColor: theme.colors.success500 }, + }, + }, + defaultVariants: { + color: 'blue', + }, + compoundVariants: [ + { condition: { size: 'small', color: 'blue' }, styles: { borderRadius: theme.radii.full } }, + { condition: { size: 'small', color: 'green' }, styles: { backgroundColor: 'notpossible' } }, + ], + })); + + const res = applyVariants({ size: 'small' })(baseTheme); + expect(res).toEqual({ + fontSize: baseTheme.fontSizes.sm, + backgroundColor: 'primary500', + borderRadius: 'full', + }); + }); + + it('correctly overrides styles though compound variants rules', () => { + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + color: { + blue: { backgroundColor: theme.colors.primary500 }, + green: { backgroundColor: theme.colors.success500 }, + }, + }, + defaultVariants: { + size: 'small', + color: 'blue', + }, + compoundVariants: [ + { condition: { size: 'small', color: 'blue' }, styles: { borderRadius: theme.radii.full } }, + { condition: { size: 'medium', color: 'green' }, styles: { backgroundColor: 'gainsboro' } }, + ], + })); + + const res = applyVariants({ size: 'medium', color: 'green' })(baseTheme); + expect(res).toEqual({ + fontSize: baseTheme.fontSizes.md, + backgroundColor: 'gainsboro', + }); + }); + + it('sanitizes vss variable keys before use', () => { + const { color } = createCssVariables('color'); + const { applyVariants } = createVariants(theme => ({ + variants: { + size: { + small: { [color]: theme.colors.primary500, fontSize: baseTheme.fontSizes.sm }, + }, + color: { + blue: { backgroundColor: color }, + }, + }, + })); + + const res = applyVariants({ size: 'small', color: 'blue' })(baseTheme); + expect(res).toEqual({ + fontSize: baseTheme.fontSizes.sm, + [color.replace('var(', '').replace(')', '')]: baseTheme.colors.primary500, + backgroundColor: color, + }); + }); + + it('gives access to props inside the config function', () => { + type Props = { size: any; color: any; isLoading: boolean }; + const { applyVariants } = createVariants((theme, props) => ({ + base: { + color: props.isLoading ? theme.colors.success500 : theme.colors.primary500, + }, + variants: {}, + })); + + const res = applyVariants({ isLoading: true } as any)(baseTheme); + expect(res).toEqual({ color: baseTheme.colors.success500 }); + }); + + it('removes variant keys from passed props', () => { + const { filterProps } = createVariants(theme => ({ + variants: { + size: { + small: { fontSize: theme.fontSizes.sm }, + medium: { fontSize: theme.fontSizes.md }, + }, + color: { + blue: { backgroundColor: theme.colors.primary500 }, + green: { backgroundColor: theme.colors.success500 }, + }, + }, + })); + + const res = filterProps({ size: 'small', color: 'blue', normalProp1: '1', normalProp2: '2' }); + expect(res).toEqual({ normalProp1: '1', normalProp2: '2' }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/animations.ts b/packages/clerk-js/src/ui-retheme/styledSystem/animations.ts new file mode 100644 index 0000000000..2875b74998 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/animations.ts @@ -0,0 +1,141 @@ +// TODO: This is forbidden by ESLint +// eslint-disable-next-line no-restricted-imports +import { keyframes } from '@emotion/react'; + +const spinning = keyframes` + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); }`; + +const dropdownSlideInScaleAndFade = keyframes` + 0% { + opacity: 0; + transform: scaleY(1) translateY(-6px); + } + + 100% { + opacity: 1; + transform: scaleY(1) translateY(0px); + } +`; + +const modalSlideAndFade = keyframes` + 0% { + opacity: 0; + transform: translateY(0.5rem); + } + 100% { + opacity: 1; + transform: translateY(0); + } +`; + +const fadeIn = keyframes` + 0% { opacity: 0; } + 100% { opacity: 1; } +`; + +const inAnimation = keyframes` + 0% { + opacity: 0; + transform: translateY(-5px); + max-height: 0; + } + 100% { + opacity: 1; + transform: translateY(0px); + max-height: 6rem; + } +`; + +const inDelayAnimation = keyframes` + 0% { + opacity: 0; + transform: translateY(-5px); + max-height: 0; + } + 50% { + opacity: 0; + transform: translateY(-5px); + max-height: 0; + } + 100% { + opacity: 1; + transform: translateY(0px); + max-height: 6rem; + } +`; + +const notificationAnimation = keyframes` + 0% { + opacity: 0; + transform: translateY(5px) scale(.5); + } + + 50% { + opacity: 1; + transform: translateY(0px) scale(1.2); + } + + 100% { + opacity: 1; + transform: translateY(0px) scale(1); + } +`; + +const outAnimation = keyframes` + 0% { + opacity:1; + translateY(0px); + max-height: 6rem; + visibility: visible; + } + 100% { + opacity: 0; + transform: translateY(5px); + max-height: 0; + visibility: visible; + } +`; + +const textInSmall = keyframes` + 0% {opacity: 0;max-height: 0;} + 100% {opacity: 1;max-height: 3rem;} +`; + +const textInBig = keyframes` + 0% {opacity: 0;max-height: 0;} + 100% {opacity: 1;max-height: 8rem;} +`; + +const blockBigIn = keyframes` + 0% {opacity: 0;max-height: 0;} + 99% {opacity: 1;max-height: 10rem;} + 100% {opacity: 1;max-height: unset;} +`; + +const expandIn = (max: string) => keyframes` + 0% {opacity: 0;max-height: 0;} + 99% {opacity: 1;max-height: ${max};} + 100% {opacity: 1;max-height: unset;} +`; + +const navbarSlideIn = keyframes` + 0% {opacity: 0; transform: translateX(-100%);} + 100% {opacity: 1; transform: translateX(0);} +`; + +export const animations = { + spinning, + dropdownSlideInScaleAndFade, + modalSlideAndFade, + fadeIn, + textInSmall, + textInBig, + blockBigIn, + expandIn, + navbarSlideIn, + inAnimation, + inDelayAnimation, + outAnimation, + notificationAnimation, +}; diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/breakpoints.tsx b/packages/clerk-js/src/ui-retheme/styledSystem/breakpoints.tsx new file mode 100644 index 0000000000..23b536d202 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/breakpoints.tsx @@ -0,0 +1,23 @@ +import { fromEntries } from '../utils/fromEntries'; + +const breakpoints = Object.freeze({ + xs: '21em', // 336px + sm: '30em', // 480px + md: '48em', // 768px + lg: '62em', // 992px + xl: '80em', // 1280px + '2xl': '96em', // 1536px +} as const); + +const deviceQueries = { + ios: '@supports (-webkit-touch-callout: none)', +} as const; + +// export const mq = Object.fromEntries( +// Object.entries(breakpoints).map(([k, v]) => [k, `@media (min-width: ${v})`]), +// ) as Record; + +export const mqu = { + ...deviceQueries, + ...fromEntries(Object.entries(breakpoints).map(([k, v]) => [k, `@media (max-width: ${v})`])), +} as Record; diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/common.ts b/packages/clerk-js/src/ui-retheme/styledSystem/common.ts new file mode 100644 index 0000000000..8471c70641 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/common.ts @@ -0,0 +1,226 @@ +import type { InternalTheme } from './types'; + +const textVariants = (t: InternalTheme) => { + const base = { + WebkitFontSmoothing: t.options.$fontSmoothing, + fontFamily: 'inherit', + }; + + const smallRegular = { + ...base, + fontWeight: t.fontWeights.$normal, + fontSize: t.fontSizes.$xs, + lineHeight: t.lineHeights.$shorter, + } as const; + + const smallMedium = { + ...smallRegular, + fontWeight: t.fontWeights.$medium, + lineHeight: t.lineHeights.$short, + } as const; + + const smallBold = { + ...smallMedium, + fontWeight: t.fontWeights.$bold, + } as const; + + const extraSmallRegular = { + ...base, + fontWeight: t.fontWeights.$normal, + fontSize: t.fontSizes.$2xs, + letterSpacing: t.letterSpacings.$normal, + lineHeight: t.lineHeights.$none, + } as const; + + const extraSmallMedium = { + ...base, + fontWeight: t.fontWeights.$medium, + fontSize: t.fontSizes.$2xs, + letterSpacing: t.letterSpacings.$normal, + lineHeight: t.lineHeights.$shortest, + } as const; + + const regularRegular = { + ...base, + fontWeight: t.fontWeights.$normal, + fontSize: t.fontSizes.$sm, + lineHeight: t.lineHeights.$shorter, + } as const; + + const regularMedium = { + ...regularRegular, + fontWeight: t.fontWeights.$medium, + } as const; + + const largeBold = { + ...base, + fontWeight: t.fontWeights.$bold, + fontSize: t.fontSizes.$md, + lineHeight: t.lineHeights.$taller, + } as const; + + const largeMedium = { + ...largeBold, + fontWeight: t.fontWeights.$medium, + }; + + const xlargeMedium = { + ...largeBold, + fontSize: t.fontSizes.$xl, + } as const; + + const xxlargeMedium = { + ...xlargeMedium, + fontSize: t.fontSizes.$2xl, + } as const; + + const buttonExtraSmallBold = { + ...extraSmallRegular, + fontWeight: t.fontWeights.$bold, + textTransform: 'uppercase', + fontFamily: t.fonts.$buttons, + } as const; + + const buttonSmallRegular = { + ...smallRegular, + fontFamily: t.fonts.$buttons, + }; + + const buttonRegularRegular = { + ...regularRegular, + fontFamily: t.fonts.$buttons, + lineHeight: t.lineHeights.$none, + }; + + const buttonRegularMedium = { + ...regularMedium, + fontFamily: t.fonts.$buttons, + lineHeight: t.lineHeights.$none, + }; + + const headingRegularRegular = { + ...regularRegular, + fontSize: t.fontSizes.$md, + } as const; + + return { + headingRegularRegular, + buttonExtraSmallBold, + buttonSmallRegular, + buttonRegularRegular, + buttonRegularMedium, + extraSmallRegular, + extraSmallMedium, + smallRegular, + smallMedium, + smallBold, + regularRegular, + regularMedium, + largeMedium, + largeBold, + xlargeMedium, + xxlargeMedium, + } as const; +}; + +const fontSizeVariants = (t: InternalTheme) => { + return { + xss: { fontSize: t.fontSizes.$2xs }, + xs: { fontSize: t.fontSizes.$xs }, + sm: { fontSize: t.fontSizes.$sm }, + } as const; +}; + +const borderVariants = (t: InternalTheme, props?: any) => { + return { + normal: { + borderRadius: t.radii.$md, + border: t.borders.$normal, + ...borderColor(t, props), + }, + } as const; +}; + +const borderColor = (t: InternalTheme, props?: any) => { + return { + borderColor: props?.hasError ? t.colors.$danger500 : t.colors.$blackAlpha300, + } as const; +}; + +const focusRing = (t: InternalTheme) => { + return { + '&:focus': { + '&::-moz-focus-inner': { border: '0' }, + WebkitTapHighlightColor: 'transparent', + boxShadow: t.shadows.$focusRing.replace('{{color}}', t.colors.$primary200), + transitionProperty: t.transitionProperty.$common, + transitionTimingFunction: t.transitionTiming.$common, + transitionDuration: t.transitionDuration.$focusRing, + }, + } as const; +}; + +const focusRingInput = (t: InternalTheme, props?: any) => { + return { + '&:focus': { + WebkitTapHighlightColor: 'transparent', + boxShadow: t.shadows.$focusRingInput.replace( + '{{color}}', + props?.hasError ? t.colors.$danger200 : t.colors.$primary200, + ), + transitionProperty: t.transitionProperty.$common, + transitionTimingFunction: t.transitionTiming.$common, + transitionDuration: t.transitionDuration.$focusRing, + }, + } as const; +}; + +const disabled = (t: InternalTheme) => { + return { + '&:disabled,&[data-disabled]': { + cursor: 'not-allowed', + pointerEvents: 'none', + opacity: t.opacity.$disabled, + }, + } as const; +}; + +const centeredFlex = (display: 'flex' | 'inline-flex' = 'flex') => ({ + display: display, + justifyContent: 'center', + alignItems: 'center', +}); + +const unstyledScrollbar = (t: InternalTheme) => ({ + '::-webkit-scrollbar': { + background: 'transparent', + width: '8px', + height: '8px', + }, + '::-webkit-scrollbar-thumb': { + background: t.colors.$blackAlpha500, + }, + '::-webkit-scrollbar-track': { + background: 'transparent', + }, +}); + +const maxHeightScroller = (t: InternalTheme) => + ({ + height: '100%', + overflowY: 'auto', + ...unstyledScrollbar(t), + } as const); + +export const common = { + textVariants, + fontSizeVariants, + borderVariants, + focusRing, + focusRingInput, + disabled, + borderColor, + centeredFlex, + maxHeightScroller, + unstyledScrollbar, +}; diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/createCssVariables.ts b/packages/clerk-js/src/ui-retheme/styledSystem/createCssVariables.ts new file mode 100644 index 0000000000..aea114a1ec --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/createCssVariables.ts @@ -0,0 +1,5 @@ +import { fromEntries } from '../utils/fromEntries'; + +export const createCssVariables = (...names: T): { [k in T[number]]: string } => { + return fromEntries(names.map(name => [name, `var(--${name})`])); +}; diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/createVariants.ts b/packages/clerk-js/src/ui-retheme/styledSystem/createVariants.ts new file mode 100644 index 0000000000..9c6f00d7af --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/createVariants.ts @@ -0,0 +1,139 @@ +import { createInfiniteAccessProxy, fastDeepMergeAndReplace } from '../utils'; +import type { InternalTheme, StyleRule } from './types'; + +type UnwrapBooleanVariant = T extends 'true' | 'false' ? boolean : T; + +type VariantDefinition = Record; + +type Variants = Record; + +type VariantNameToKeyMap = { + [key in keyof V]?: UnwrapBooleanVariant; +}; + +type DefaultVariants = VariantNameToKeyMap; + +type CompoundVariant = { + condition: VariantNameToKeyMap; + styles?: StyleRule; +}; + +type CreateVariantsConfig = { + base?: StyleRule; + variants: V; + compoundVariants?: Array>; + defaultVariants?: DefaultVariants; +}; + +type ApplyVariants = { + (props?: VariantNameToKeyMap): (theme: T) => StyleRule; +}; + +export type StyleVariants any> = Parameters[0]; + +type CreateVariantsReturn = { + applyVariants: ApplyVariants; + filterProps: (props: Props) => { + [k in Exclude]: Props[k]; + }; +}; + +interface CreateVariants { +

, T = InternalTheme, V extends Variants = Variants>( + param: (theme: T, props: P) => CreateVariantsConfig, + ): CreateVariantsReturn; +} + +export const createVariants: CreateVariants = configFn => { + const applyVariants = + (props: any = {}) => + (theme: any) => { + const { base, variants = {}, compoundVariants = [], defaultVariants = {} } = configFn(theme, props); + const variantsToApply = calculateVariantsToBeApplied(variants, props, defaultVariants); + const computedStyles = {}; + applyBaseRules(computedStyles, base); + applyVariantRules(computedStyles, variantsToApply, variants); + applyCompoundVariantRules(computedStyles, variantsToApply, compoundVariants); + sanitizeCssVariables(computedStyles); + return computedStyles; + }; + + // We need to get the variant keys in order to remove them from the props. + // However, we don't have access to the theme value when createVariants is called. + // Instead of the theme, we pass an infinite proxy because we only care about + // the keys of the returned object and not the actual values. + const fakeProxyTheme = createInfiniteAccessProxy(); + const variantKeys = Object.keys(configFn(fakeProxyTheme, fakeProxyTheme).variants || {}); + const filterProps = (props: any) => getPropsWithoutVariants(props, variantKeys); + return { applyVariants, filterProps } as any; +}; + +const getPropsWithoutVariants = (props: Record, variants: string[]) => { + const res = { ...props }; + for (const key of variants) { + delete res[key]; + } + return res; +}; + +const applyBaseRules = (computedStyles: any, base?: StyleRule) => { + if (base && typeof base === 'object') { + Object.assign(computedStyles, base); + } +}; + +const applyVariantRules = (computedStyles: any, variantsToApply: Variants, variants: Variants) => { + for (const key in variantsToApply) { + // @ts-ignore + fastDeepMergeAndReplace(variants[key][variantsToApply[key]], computedStyles); + } +}; + +const applyCompoundVariantRules = ( + computedStyles: any, + variantsToApply: Variants, + compoundVariants: Array>, +) => { + for (const compoundVariant of compoundVariants) { + if (conditionMatches(compoundVariant, variantsToApply)) { + fastDeepMergeAndReplace(compoundVariant.styles, computedStyles); + } + } +}; + +const sanitizeCssVariables = (computedStyles: any) => { + for (const key in computedStyles) { + if (key.startsWith('var(')) { + computedStyles[key.slice(4, -1)] = computedStyles[key]; + delete computedStyles[key]; + } + } +}; + +const calculateVariantsToBeApplied = ( + variants: Variants, + props: Record, + defaultVariants: DefaultVariants, +) => { + const variantsToApply = {}; + for (const key in variants) { + if (key in props) { + // @ts-ignore + variantsToApply[key] = props[key]; + } else if (key in defaultVariants) { + // @ts-ignore + variantsToApply[key] = defaultVariants[key as keyof typeof defaultVariants]; + } + } + return variantsToApply; +}; + +const conditionMatches = ({ condition }: CompoundVariant, variants: Variants) => { + for (const key in condition) { + // @ts-ignore + if (condition[key] !== variants[key]) { + return false; + } + } + return true; +}; diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/emotion.d.ts b/packages/clerk-js/src/ui-retheme/styledSystem/emotion.d.ts new file mode 100644 index 0000000000..d07e3b1662 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/emotion.d.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line no-restricted-imports +import '@emotion/react'; + +import type { InternalTheme } from '../foundations'; + +declare module '@emotion/react' { + export interface Theme extends InternalTheme {} +} diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/index.ts b/packages/clerk-js/src/ui-retheme/styledSystem/index.ts new file mode 100644 index 0000000000..9b37515dba --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/index.ts @@ -0,0 +1,7 @@ +export * from './types'; +export * from './InternalThemeProvider'; +export * from './createVariants'; +export * from './createCssVariables'; +export * from './common'; +export * from './animations'; +export * from './breakpoints'; diff --git a/packages/clerk-js/src/ui-retheme/styledSystem/types.ts b/packages/clerk-js/src/ui-retheme/styledSystem/types.ts new file mode 100644 index 0000000000..082f6ab0e1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/styledSystem/types.ts @@ -0,0 +1,63 @@ +// eslint-disable-next-line no-restricted-imports +import type { Interpolation as _Interpolation } from '@emotion/react'; +import type React from 'react'; + +import type { InternalTheme } from '../foundations'; + +type StyleRule = Exclude<_Interpolation, string | number | boolean>; + +/** + * Primitives can override their styles using the css prop. + * For customising layout/theme, prefer using the props defined on the component. + */ +type ThemableCssProp = ((params: InternalTheme) => StyleRule) | StyleRule; +type CssProp = { css?: ThemableCssProp }; + +export type AsProp = { as?: React.ElementType | undefined }; + +type ElementProps = { + div: React.JSX.IntrinsicElements['div']; + input: React.JSX.IntrinsicElements['input']; + button: React.JSX.IntrinsicElements['button']; + heading: React.JSX.IntrinsicElements['h1']; + p: React.JSX.IntrinsicElements['p']; + a: React.JSX.IntrinsicElements['a']; + label: React.JSX.IntrinsicElements['label']; + img: React.JSX.IntrinsicElements['img']; + form: React.JSX.IntrinsicElements['form']; + table: React.JSX.IntrinsicElements['table']; + thead: React.JSX.IntrinsicElements['thead']; + tbody: React.JSX.IntrinsicElements['tbody']; + th: React.JSX.IntrinsicElements['th']; + tr: React.JSX.IntrinsicElements['tr']; + td: React.JSX.IntrinsicElements['td']; +}; + +/** + * Some elements, like Flex can accept StateProps + * simply because they need to be targettable when their container + * component has a specific state. We then remove the props + * before rendering the element to the DOM + */ +type StateProps = Partial>; +/** + * The form control elements can also accept a isRequired prop on top of the StateProps + * We're handling it differently since this is a prop that cannot change - a required field + * will remain required throughout the lifecycle of the component + */ +type RequiredProp = Partial>; + +type PrimitiveProps = ElementProps[HtmlT] & CssProp; +type PickSiblingProps[0]> = Pick[0], T>; +type PropsOfComponent any> = Parameters[0]; + +export type { + InternalTheme, + PrimitiveProps, + PickSiblingProps, + PropsOfComponent, + StyleRule, + ThemableCssProp, + StateProps, + RequiredProp, +}; diff --git a/packages/clerk-js/src/ui-retheme/types.ts b/packages/clerk-js/src/ui-retheme/types.ts new file mode 100644 index 0000000000..f5ed046777 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/types.ts @@ -0,0 +1,83 @@ +import type { + CreateOrganizationProps, + OrganizationListProps, + OrganizationProfileProps, + OrganizationSwitcherProps, + SignInProps, + SignUpProps, + UserButtonProps, + UserProfileProps, +} from '@clerk/types'; + +export type { + SignInProps, + SignUpProps, + UserButtonProps, + UserProfileProps, + OrganizationSwitcherProps, + OrganizationProfileProps, + CreateOrganizationProps, + OrganizationListProps, +}; + +export type AvailableComponentProps = + | SignInProps + | SignUpProps + | UserProfileProps + | UserButtonProps + | OrganizationSwitcherProps + | OrganizationProfileProps + | CreateOrganizationProps + | OrganizationListProps; + +type ComponentMode = 'modal' | 'mounted'; + +export type SignInCtx = SignInProps & { + componentName: 'SignIn'; + mode?: ComponentMode; +}; + +export type UserProfileCtx = UserProfileProps & { + componentName: 'UserProfile'; + mode?: ComponentMode; +}; + +export type SignUpCtx = SignUpProps & { + componentName: 'SignUp'; + mode?: ComponentMode; +}; + +export type UserButtonCtx = UserButtonProps & { + componentName: 'UserButton'; + mode?: ComponentMode; +}; + +export type OrganizationProfileCtx = OrganizationProfileProps & { + componentName: 'OrganizationProfile'; + mode?: ComponentMode; +}; + +export type CreateOrganizationCtx = CreateOrganizationProps & { + componentName: 'CreateOrganization'; + mode?: ComponentMode; +}; + +export type OrganizationSwitcherCtx = OrganizationSwitcherProps & { + componentName: 'OrganizationSwitcher'; + mode?: ComponentMode; +}; + +export type OrganizationListCtx = OrganizationListProps & { + componentName: 'OrganizationList'; + mode?: ComponentMode; +}; + +export type AvailableComponentCtx = + | SignInCtx + | SignUpCtx + | UserButtonCtx + | UserProfileCtx + | OrganizationProfileCtx + | CreateOrganizationCtx + | OrganizationSwitcherCtx + | OrganizationListCtx; diff --git a/packages/clerk-js/src/ui-retheme/utils/ExternalElementMounter.tsx b/packages/clerk-js/src/ui-retheme/utils/ExternalElementMounter.tsx new file mode 100644 index 0000000000..9ab73a7015 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/ExternalElementMounter.tsx @@ -0,0 +1,28 @@ +import { useEffect, useRef } from 'react'; + +type ExternalElementMounterProps = { + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +export const ExternalElementMounter = ({ mount, unmount, ...rest }: ExternalElementMounterProps) => { + const nodeRef = useRef(null); + + useEffect(() => { + let elRef: HTMLDivElement | undefined; + if (nodeRef.current) { + elRef = nodeRef.current; + mount(nodeRef.current); + } + return () => { + unmount(elRef); + }; + }, [nodeRef.current]); + + return ( +

+ ); +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/__tests__/colors.test.ts b/packages/clerk-js/src/ui-retheme/utils/__tests__/colors.test.ts new file mode 100644 index 0000000000..7762f9dc9c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/__tests__/colors.test.ts @@ -0,0 +1,36 @@ +import type { HslaColor } from '@clerk/types'; + +import { colors } from '../colors'; + +describe('colors.toHslaColor(color)', function () { + const hsla = { h: 195, s: 100, l: 50, a: 1 }; + const cases: Array<[string, any]> = [ + // ['', undefined], + // ['00bfff', hsla], + ['transparent', { h: 0, s: 0, l: 0, a: 0 }], + ['#00bfff', hsla], + ['rgb(0, 191, 255)', hsla], + ['rgba(0, 191, 255, 0.3)', { ...hsla, a: 0.3 }], + ['hsl(195, 100%, 50%)', hsla], + ['hsla(195, 100%, 50%, 1)', hsla], + ]; + + it.each(cases)('colors.toHslaColor(%s) => %s', (a, expected) => { + expect(colors.toHslaColor(a)).toEqual(expected); + }); +}); + +describe('colors.toHslaColor(color)', function () { + const cases: Array<[HslaColor, any]> = [ + [colors.toHslaColor('transparent'), `hsla(0, 0%, 0%, 0)`], + [colors.toHslaColor('#00bfff'), 'hsla(195, 100%, 50%, 1)'], + [colors.toHslaColor('rgb(0, 191, 255)'), 'hsla(195, 100%, 50%, 1)'], + [colors.toHslaColor('rgba(0, 191, 255, 0.3)'), 'hsla(195, 100%, 50%, 0.3)'], + [colors.toHslaColor('hsl(195, 100%, 50%)'), 'hsla(195, 100%, 50%, 1)'], + [colors.toHslaColor('hsla(195, 100%, 50%, 1)'), 'hsla(195, 100%, 50%, 1)'], + ]; + + it.each(cases)('colors.toHslaColor(%s) => %s', (a, expected) => { + expect(colors.toHslaString(a)).toEqual(expected); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/utils/__tests__/createCustomPages.test.ts b/packages/clerk-js/src/ui-retheme/utils/__tests__/createCustomPages.test.ts new file mode 100644 index 0000000000..0ddff30479 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/__tests__/createCustomPages.test.ts @@ -0,0 +1,616 @@ +import type { CustomPage } from '@clerk/types'; + +import { createOrganizationProfileCustomPages, createUserProfileCustomPages } from '../createCustomPages'; + +describe('createCustomPages', () => { + describe('createUserProfileCustomPages', () => { + it('should return the default pages if no custom pages are passed', () => { + const { routes, contents } = createUserProfileCustomPages([]); + expect(routes.length).toEqual(2); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(contents.length).toEqual(0); + }); + + it('should return the custom pages after the default pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('should reorder the default pages when their label is used to target them', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('account'); + expect(routes[2].id).toEqual('security'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('ignores invalid entries', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { label: 'Aaaaaa' }, + { label: 'account', mount: () => undefined }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('account'); + expect(routes[2].id).toEqual('security'); + expect(routes[3].name).toEqual('Custom2'); + }); + + it('sets the path of the first page to be the root (/)', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('account'); + expect(routes[2].path).toEqual('account'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of both account and security pages to root (/) if account is first', () => { + const customPages: CustomPage[] = [ + { label: 'account' }, + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('custom1'); + expect(routes[2].path).toEqual('/'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of both account and security pages to root (/) if security is first', () => { + const customPages: CustomPage[] = [ + { label: 'security' }, + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('custom1'); + expect(routes[2].path).toEqual('/'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('throws if the first item in the navbar is an external link', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createUserProfileCustomPages(customPages)).toThrow(); + }); + + it('adds an external link to the navbar routes', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Link1'); + expect(contents.length).toEqual(1); + expect(contents[0].url).toEqual('custom1'); + }); + + it('sanitizes the path for external links', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: 'https://www.fullurl.com', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link2', + url: '/url-with-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link3', + url: 'url-without-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(5); + expect(routes[2].path).toEqual('https://www.fullurl.com'); + expect(routes[3].path).toEqual('/url-with-slash'); + expect(routes[4].path).toEqual('/url-without-slash'); + }); + + it('sanitizes the path for custom pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: '/url-with-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Page2', + url: 'url-without-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[2].path).toEqual('url-with-slash'); + expect(routes[3].path).toEqual('url-without-slash'); + }); + + it('throws when a custom page has an absolute URL', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: 'https://www.fullurl.com', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createUserProfileCustomPages(customPages)).toThrow(); + }); + }); + + describe('createOrganizationProfileCustomPages', () => { + it('should return the default pages if no custom pages are passed', () => { + const { routes, contents } = createOrganizationProfileCustomPages([]); + expect(routes.length).toEqual(2); + expect(routes[0].id).toEqual('members'); + expect(routes[1].id).toEqual('settings'); + expect(contents.length).toEqual(0); + }); + + it('should return the custom pages after the default pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('members'); + expect(routes[1].id).toEqual('settings'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('should reorder the default pages when their label is used to target them', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('members'); + expect(routes[2].id).toEqual('settings'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('ignores invalid entries', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { label: 'Aaaaaa' }, + { label: 'members', mount: () => undefined }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('members'); + expect(routes[2].id).toEqual('settings'); + expect(routes[3].name).toEqual('Custom2'); + }); + + it('sets the path of the first page to be the root (/)', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('organization-members'); + expect(routes[2].path).toEqual('organization-settings'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of members pages to root (/) if it is first', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('custom1'); + expect(routes[2].path).toEqual('organization-settings'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of settings pages to root (/) if it is first', () => { + const customPages: CustomPage[] = [ + { label: 'settings' }, + { label: 'members' }, + + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('organization-members'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('throws if the first item in the navbar is an external link', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createOrganizationProfileCustomPages(customPages)).toThrow(); + }); + + it('adds an external link to the navbar routes', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('members'); + expect(routes[1].id).toEqual('settings'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Link1'); + expect(contents.length).toEqual(1); + expect(contents[0].url).toEqual('custom1'); + }); + + it('sanitizes the path for external links', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: 'https://www.fullurl.com', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link2', + url: '/url-with-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link3', + url: 'url-without-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(5); + expect(routes[2].path).toEqual('https://www.fullurl.com'); + expect(routes[3].path).toEqual('/url-with-slash'); + expect(routes[4].path).toEqual('/url-without-slash'); + }); + + it('sanitizes the path for custom pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: '/url-with-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Page2', + url: 'url-without-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[2].path).toEqual('url-with-slash'); + expect(routes[3].path).toEqual('url-without-slash'); + }); + + it('throws when a custom page has an absolute URL', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: 'https://www.fullurl.com', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createOrganizationProfileCustomPages(customPages)).toThrow(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/utils/__tests__/fastDeepMerge.test.ts b/packages/clerk-js/src/ui-retheme/utils/__tests__/fastDeepMerge.test.ts new file mode 100644 index 0000000000..8c5c91e27d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/__tests__/fastDeepMerge.test.ts @@ -0,0 +1,61 @@ +import { fastDeepMergeAndKeep, fastDeepMergeAndReplace } from '../fastDeepMerge'; + +describe('fastDeepMergeReplace', () => { + it('merges simple objects', () => { + const source = { a: '1', b: '2', c: '3' }; + const target = {}; + fastDeepMergeAndReplace(source, target); + expect(target).toEqual({ a: '1', b: '2', c: '3' }); + }); + + it('merges all keys when objects have different keys', () => { + const source = { a: '1', b: '2', c: '3' }; + const target = { d: '4', e: '5', f: '6' }; + fastDeepMergeAndReplace(source, target); + expect(target).toEqual({ a: '1', b: '2', c: '3', d: '4', e: '5', f: '6' }); + }); + + it('source overrides target when they have same keys', () => { + const source = { a: '1', b: '2', c: '3' }; + const target = { a: '10' }; + fastDeepMergeAndReplace(source, target); + expect(target).toEqual({ a: '1', b: '2', c: '3' }); + }); + + it('source overrides target when they have same keys even for nested objects', () => { + const source = { a: '1', b: '2', c: '3', obj: { a: '1', b: '2' } }; + const target = { a: '10', obj: { a: '10', b: '20' } }; + fastDeepMergeAndReplace(source, target); + expect(target).toEqual({ a: '1', b: '2', c: '3', obj: { a: '1', b: '2' } }); + }); +}); + +describe('fastDeepMergeKeep', () => { + it('merges simple objects', () => { + const source = { a: '1', b: '2', c: '3' }; + const target = {}; + fastDeepMergeAndKeep(source, target); + expect(target).toEqual({ a: '1', b: '2', c: '3' }); + }); + + it('merges all keys when objects have different keys', () => { + const source = { a: '1', b: '2', c: '3' }; + const target = { d: '4', e: '5', f: '6' }; + fastDeepMergeAndKeep(source, target); + expect(target).toEqual({ a: '1', b: '2', c: '3', d: '4', e: '5', f: '6' }); + }); + + it('source overrides target when they have same keys', () => { + const source = { a: '1', b: '2', c: '3' }; + const target = { a: '10' }; + fastDeepMergeAndKeep(source, target); + expect(target).toEqual({ a: '10', b: '2', c: '3' }); + }); + + it('source overrides target when they have same keys even for nested objects', () => { + const source = { a: '1', b: '2', c: '3', obj: { a: '1', b: '2' } }; + const target = { a: '10', obj: { a: '10', b: '20' } }; + fastDeepMergeAndKeep(source, target); + expect(target).toEqual({ a: '10', b: '2', c: '3', obj: { a: '10', b: '20' } }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/utils/__tests__/phoneUtils.test.ts b/packages/clerk-js/src/ui-retheme/utils/__tests__/phoneUtils.test.ts new file mode 100644 index 0000000000..bfa3b32fd8 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/__tests__/phoneUtils.test.ts @@ -0,0 +1,223 @@ +import { + extractDigits, + formatPhoneNumber, + getCountryFromPhoneString, + getCountryIsoFromFormattedNumber, + getFlagEmojiFromCountryIso, +} from '../phoneUtils'; + +describe('phoneUtils', () => { + describe('countryIsoToFlagEmoji(iso)', () => { + it('handles undefined', () => { + const res = getFlagEmojiFromCountryIso(undefined as any); + expect(res).toBe('🇺🇸'); + }); + + it('handles us iso', () => { + const res = getFlagEmojiFromCountryIso('us'); + expect(res).toBe('🇺🇸'); + }); + + it('handles gr iso', () => { + const res = getFlagEmojiFromCountryIso('gr'); + expect(res).toBe('🇬🇷'); + }); + }); + + describe('extractDigits(formattedPhone)', () => { + it('handles undefined', () => { + const res = extractDigits(undefined as any); + expect(res).toBe(''); + }); + + it('handles empty string', () => { + const res = extractDigits(''); + expect(res).toBe(''); + }); + + it('extracts digits from formatted phone number', () => { + const res = extractDigits('+1 (202) 123 123 1234'); + expect(res).toBe('12021231231234'); + }); + + it('extracts digits from number with only digits', () => { + const res = extractDigits('12021231231234'); + expect(res).toBe('12021231231234'); + }); + }); + + describe('formatPhoneNumber(formattedPhone,pattern)', () => { + it('handles edge cases', () => { + const res = formatPhoneNumber('', undefined); + expect(res).toBe(''); + }); + + it('does not format a number with less than 3 digits', () => { + const pattern = '(...) ... ....'; + expect(formatPhoneNumber('1', pattern)).toBe('1'); + expect(formatPhoneNumber('123', pattern)).toBe('123'); + }); + + it('formats a number according to pattern before max number of digits is reached', () => { + const pattern = '(...) ... ....'; + expect(formatPhoneNumber('1231', pattern)).toBe('(123) 1'); + expect(formatPhoneNumber('1231231', pattern)).toBe('(123) 123 1'); + }); + + it('formats a number according to pattern when max number of digits is reached', () => { + const cases = [ + { + pattern: '(...) ... ....', + result: '(123) 123 1234', + }, + { + pattern: '..........', + result: '1231231234', + }, + { + pattern: ' .-.......(.). ', + result: ' 1-2312312(3)4', + }, + ]; + cases.forEach(({ pattern, result }) => { + expect(formatPhoneNumber('1231231234', pattern)).toBe(result); + }); + }); + + // https://en.wikipedia.org/wiki/E.164 + it('respects the E.164 standard', () => { + const cases = [ + { + input: '123 45678901', + pattern: '... .......', + result: '123 45678901', + countryCode: undefined, + }, + { + input: '123 45678901', + pattern: '... .......', + result: '123 45678901', + countryCode: '49', + }, + { + input: '1234567890123', + pattern: '(...) ...-....', + result: '(123) 456-7890123', + countryCode: '1', + }, + { + input: '1234567890123456', + pattern: '(...) ...-....', + result: '(123) 456-7890123', + countryCode: '1', + }, + { + input: '123 456789012345', + pattern: '... .......', + result: '123 456789012', + countryCode: '49', + }, + ]; + cases.forEach(({ input, pattern, result, countryCode }) => { + expect(formatPhoneNumber(input, pattern, countryCode)).toBe(result); + }); + }); + + it('immediately returns if the input is too short', () => { + const cases = [ + { + input: '12', + pattern: '..-..', + result: '12', + }, + { + input: '123', + pattern: '..-..', + result: '123', + }, + { + input: '1234', + pattern: '..-..', + result: '12-34', + }, + ]; + cases.forEach(({ input, pattern, result }) => { + expect(formatPhoneNumber(input, pattern)).toBe(result); + }); + }); + + it('formats a number according to pattern', () => { + const cases = [ + { + pattern: '....', + result: '1234', + }, + { + pattern: '. ()-(..)-() .', + result: '1 ()-(23)-() 4', + }, + ]; + cases.forEach(({ pattern, result }) => { + expect(formatPhoneNumber('1234', pattern)).toBe(result); + }); + }); + }); + + describe('getCountryIsoFromFormattedNumber(formattedNumber)', () => { + it('handles edge cases', () => { + const res = getCountryIsoFromFormattedNumber(undefined as any); + expect(res).toBe('us'); + }); + + it('fallbacks to us for very short (potentially wrong) numbers', () => { + const res = getCountryIsoFromFormattedNumber('123'); + expect(res).toBe('us'); + }); + + it('handles a US phone starting with 1 with a known US subarea following', () => { + const res = getCountryIsoFromFormattedNumber('+1 (202) 123 1234'); + expect(res).toBe('us'); + }); + + it('handles a CA phone starting with 1 with a known CA subarea following', () => { + const res = getCountryIsoFromFormattedNumber('+1 613-555-0150'); + expect(res).toBe('ca'); + }); + + it('prioritizes US for a non-US phone starting with 1 that has a potential non-US code', () => { + const res = getCountryIsoFromFormattedNumber('+1242 123123'); + expect(res).toBe('us'); + }); + + it('handles a non-US phone starting with code != 1', () => { + const res = getCountryIsoFromFormattedNumber('+30 6999999999'); + expect(res).toBe('gr'); + }); + + it('handles a non-US phone without a format pattern', () => { + expect(getCountryIsoFromFormattedNumber('+71111111111')).toBe('ru'); + }); + }); + + describe('getLongestValidCountryCode(value)', () => { + it('finds valid country and returns phone number value without country code', () => { + const res = getCountryFromPhoneString('+12064563059'); + expect(res.number).toBe('2064563059'); + expect(res.country).not.toBe(undefined); + expect(res.country.code).toBe('1'); + }); + + it('finds valid country by applying priority', () => { + const res = getCountryFromPhoneString('+1242123123'); + expect(res.number).toBe('242123123'); + expect(res.country).not.toBe(undefined); + expect(res.country.code).toBe('1'); + }); + + it('falls back to US if a valid country code is not found', () => { + const res = getCountryFromPhoneString('+699090909090'); + expect(res.number).toBe('99090909090'); + expect(res.country.iso).toBe('us'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/utils/colorPredicates.ts b/packages/clerk-js/src/ui-retheme/utils/colorPredicates.ts new file mode 100644 index 0000000000..53348167be --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/colorPredicates.ts @@ -0,0 +1,37 @@ +import type { Color, ColorString, HslaColor, RgbaColor, TransparentColor } from '@clerk/types'; + +const IS_HEX_COLOR_REGEX = /^#?([A-F0-9]{6}|[A-F0-9]{3})$/i; + +const IS_RGB_COLOR_REGEX = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i; +const IS_RGBA_COLOR_REGEX = /^rgba\((\d+),\s*(\d+),\s*(\d+)(,\s*\d+(\.\d+)?)\)$/i; + +const IS_HSL_COLOR_REGEX = /^hsl\((\d+),\s*([\d.]+)%,\s*([\d.]+)%\)$/i; +const IS_HSLA_COLOR_REGEX = /^hsla\((\d+),\s*([\d.]+)%,\s*([\d.]+)%(,\s*\d+(\.\d+)?)*\)$/i; + +export const isValidHexString = (s: string): s is ColorString => { + return !!s.match(IS_HEX_COLOR_REGEX); +}; + +export const isValidRgbaString = (s: string): s is ColorString => { + return !!(s.match(IS_RGB_COLOR_REGEX) || s.match(IS_RGBA_COLOR_REGEX)); +}; + +export const isValidHslaString = (s: string): s is ColorString => { + return !!s.match(IS_HSL_COLOR_REGEX) || !!s.match(IS_HSLA_COLOR_REGEX); +}; + +export const isRGBColor = (c: Color): c is RgbaColor => { + return typeof c !== 'string' && 'r' in c; +}; + +export const isHSLColor = (c: Color): c is HslaColor => { + return typeof c !== 'string' && 'h' in c; +}; + +export const isTransparent = (c: Color): c is TransparentColor => { + return c === 'transparent'; +}; + +export const hasAlpha = (color: Color): boolean => { + return typeof color !== 'string' && color.a != undefined && color.a < 1; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/colorTransformations.ts b/packages/clerk-js/src/ui-retheme/utils/colorTransformations.ts new file mode 100644 index 0000000000..25e874a40e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/colorTransformations.ts @@ -0,0 +1,123 @@ +import type { Color, HslaColor, RgbaColor, TransparentColor } from '@clerk/types'; + +import { + isHSLColor, + isRGBColor, + isTransparent, + isValidHexString, + isValidHslaString, + isValidRgbaString, +} from './colorPredicates'; + +const CLEAN_HSLA_REGEX = /[hsla()]/g; +const CLEAN_RGBA_REGEX = /[rgba()]/g; + +export const stringToHslaColor = (value: string | undefined): HslaColor | undefined => { + if (!value) { + return undefined; + } + if (value === 'transparent') { + return { h: 0, s: 0, l: 0, a: 0 }; + } + if (isValidHexString(value)) { + return hexStringToHslaColor(value); + } + if (isValidHslaString(value)) { + return parseHslaString(value); + } + if (isValidRgbaString(value)) { + return rgbaStringToHslaColor(value); + } + return undefined; +}; + +export const colorToSameTypeString = (color: Color): string | TransparentColor => { + if (typeof color === 'string' && (isValidHexString(color) || isTransparent(color))) { + return color; + } + if (isRGBColor(color)) { + return rgbaColorToRgbaString(color); + } + if (isHSLColor(color)) { + return hslaColorToHslaString(color); + } + return ''; +}; + +export const hexStringToRgbaColor = (hex: string): RgbaColor => { + hex = hex.replace('#', ''); + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + return { r, g, b }; +}; + +const rgbaColorToRgbaString = (color: RgbaColor): string => { + const { a, b, g, r } = color; + return color.a === 0 ? 'transparent' : color.a != undefined ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`; +}; + +export const hslaColorToHslaString = (color: HslaColor): string => { + const { h, s, l, a } = color; + const sPerc = Math.round(s * 100); + const lPerc = Math.round(l * 100); + return color.a === 0 + ? 'transparent' + : color.a != undefined + ? `hsla(${h},${sPerc}%,${lPerc}%,${a})` + : `hsl(${h},${sPerc}%,${lPerc}%)`; +}; + +const hexStringToHslaColor = (hex: string): HslaColor => { + const rgbaString = colorToSameTypeString(hexStringToRgbaColor(hex)); + return rgbaStringToHslaColor(rgbaString); +}; + +const rgbaStringToHslaColor = (rgba: string): HslaColor => { + const rgbaColor = parseRgbaString(rgba); + const r = rgbaColor.r / 255; + const g = rgbaColor.g / 255; + const b = rgbaColor.b / 255; + + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h, s; + const l = (max + min) / 2; + + if (max == min) { + h = s = 0; + } else { + const d = max - min; + s = l >= 0.5 ? d / (2 - (max + min)) : d / (max + min); + switch (max) { + case r: + h = ((g - b) / d) * 60; + break; + case g: + h = ((b - r) / d + 2) * 60; + break; + default: + h = ((r - g) / d + 4) * 60; + break; + } + } + + const a = rgbaColor.a || 1; + return { h: Math.round(h), s, l, a }; +}; + +const parseRgbaString = (str: string): RgbaColor => { + const [r, g, b, a] = str + .replace(CLEAN_RGBA_REGEX, '') + .split(',') + .map(c => Number.parseFloat(c)); + return { r, g, b, a }; +}; + +const parseHslaString = (str: string): HslaColor => { + const [h, s, l, a] = str + .replace(CLEAN_HSLA_REGEX, '') + .split(',') + .map(c => Number.parseFloat(c)); + return { h, s: s / 100, l: l / 100, a }; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/colors.ts b/packages/clerk-js/src/ui-retheme/utils/colors.ts new file mode 100644 index 0000000000..c5423c911d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/colors.ts @@ -0,0 +1,347 @@ +/* eslint-disable no-useless-escape */ +/** + * These helpers have been extracted from the following libraries, + * converted to Typescript and adapted to our needs. + * + * https://github.com/Qix-/color-convert + * https://github.com/Qix-/color-name + * https://github.com/Qix-/color + */ + +import type { HslaColor, HslaColorString } from '@clerk/types'; + +const abbrRegex = /^#([a-f0-9]{3,4})$/i; +const hexRegex = /^#([a-f0-9]{6})([a-f0-9]{2})?$/i; +const rgbaRegex = + /^rgba?\(\s*([+-]?\d+)(?=[\s,])\s*(?:,\s*)?([+-]?\d+)(?=[\s,])\s*(?:,\s*)?([+-]?\d+)\s*(?:[,|\/]\s*([+-]?[\d\.]+)(%?)\s*)?\)$/; +const perRegex = + /^rgba?\(\s*([+-]?[\d\.]+)\%\s*,?\s*([+-]?[\d\.]+)\%\s*,?\s*([+-]?[\d\.]+)\%\s*(?:[,|\/]\s*([+-]?[\d\.]+)(%?)\s*)?\)$/; +const hslRegex = + /^hsla?\(\s*([+-]?(?:\d{0,3}\.)?\d+)(?:deg)?\s*,?\s*([+-]?[\d\.]+)%\s*,?\s*([+-]?[\d\.]+)%\s*(?:[,|\/]\s*([+-]?(?=\.\d|\d)(?:0|[1-9]\d*)?(?:\.\d*)?(?:[eE][+-]?\d+)?)\s*)?\)$/; +const hwbRegex = + /^hwb\(\s*([+-]?\d{0,3}(?:\.\d+)?)(?:deg)?\s*,\s*([+-]?[\d\.]+)%\s*,\s*([+-]?[\d\.]+)%\s*(?:,\s*([+-]?(?=\.\d|\d)(?:0|[1-9]\d*)?(?:\.\d*)?(?:[eE][+-]?\d+)?)\s*)?\)$/; + +// List of common css keywords. +// https://github.com/colorjs/color-name +const keywords = { + black: [0, 0, 0, 1], + blue: [0, 0, 255, 1], + red: [255, 0, 0, 1], + green: [0, 128, 0, 1], + grey: [128, 128, 128, 1], + gray: [128, 128, 128, 1], + white: [255, 255, 255, 1], + yellow: [255, 255, 0, 1], + transparent: [0, 0, 0, 0], +}; + +type ColorTuple = [number, number, number, number?]; +type ParsedResult = { model: 'hsl' | 'rgb' | 'hwb'; value: ColorTuple }; + +const clamp = (num: number, min: number, max: number) => Math.min(Math.max(min, num), max); + +const parseRgb = (str: string): ColorTuple | null => { + if (!str) { + return null; + } + const rgb = [0, 0, 0, 1]; + let match; + let i; + let hexAlpha; + + if ((match = str.match(hexRegex))) { + hexAlpha = match[2]; + match = match[1]; + + for (i = 0; i < 3; i++) { + const i2 = i * 2; + rgb[i] = parseInt(match.slice(i2, i2 + 2), 16); + } + + if (hexAlpha) { + rgb[3] = parseInt(hexAlpha, 16) / 255; + } + } else if ((match = str.match(abbrRegex))) { + match = match[1]; + hexAlpha = match[3]; + + for (i = 0; i < 3; i++) { + rgb[i] = parseInt(match[i] + match[i], 16); + } + + if (hexAlpha) { + rgb[3] = parseInt(hexAlpha + hexAlpha, 16) / 255; + } + } else if ((match = str.match(rgbaRegex))) { + for (i = 0; i < 3; i++) { + rgb[i] = parseInt(match[i + 1], 0); + } + + if (match[4]) { + if (match[5]) { + rgb[3] = parseFloat(match[4]) * 0.01; + } else { + rgb[3] = parseFloat(match[4]); + } + } + } else if ((match = str.match(perRegex))) { + for (i = 0; i < 3; i++) { + rgb[i] = Math.round(parseFloat(match[i + 1]) * 2.55); + } + + if (match[4]) { + if (match[5]) { + rgb[3] = parseFloat(match[4]) * 0.01; + } else { + rgb[3] = parseFloat(match[4]); + } + } + } else if (str in keywords) { + return keywords[str as keyof typeof keywords] as any; + } else { + return null; + } + + for (i = 0; i < 3; i++) { + rgb[i] = clamp(rgb[i], 0, 255); + } + rgb[3] = clamp(rgb[3], 0, 1); + + return rgb as any; +}; + +const parseHsl = (str: string): ColorTuple | null => { + if (!str) { + return null; + } + const match = str.match(hslRegex); + return match ? transformHslOrHwb(match) : null; +}; + +const parseHwb = function (str: string): ColorTuple | null { + if (!str) { + return null; + } + const match = str.match(hwbRegex); + return match ? transformHslOrHwb(match) : null; +}; + +const transformHslOrHwb = (match: any): ColorTuple => { + const alpha = parseFloat(match[4]); + const hh = ((parseFloat(match[1]) % 360) + 360) % 360; + const sw = clamp(parseFloat(match[2]), 0, 100); + const lb = clamp(parseFloat(match[3]), 0, 100); + const aa = clamp(isNaN(alpha) ? 1 : alpha, 0, 1); + return [hh, sw, lb, aa]; +}; + +const hslaTupleToHslaColor = (hsla: ColorTuple): HslaColor => { + return { h: hsla[0], s: hsla[1], l: hsla[2], a: hsla[3] ?? 1 }; +}; + +const hwbTupleToRgbTuple = (hwb: ColorTuple): ColorTuple => { + const h = hwb[0] / 360; + let wh = hwb[1] / 100; + let bl = hwb[2] / 100; + const a = hwb[3] ?? 1; + const ratio = wh + bl; + let f; + + // Wh + bl cant be > 1 + if (ratio > 1) { + wh /= ratio; + bl /= ratio; + } + + const i = Math.floor(6 * h); + const v = 1 - bl; + f = 6 * h - i; + + if ((i & 0x01) !== 0) { + f = 1 - f; + } + + const n = wh + f * (v - wh); // Linear interpolation + + let r; + let g; + let b; + + switch (i) { + default: + case 6: + case 0: + r = v; + g = n; + b = wh; + break; + case 1: + r = n; + g = v; + b = wh; + break; + case 2: + r = wh; + g = v; + b = n; + break; + case 3: + r = wh; + g = n; + b = v; + break; + case 4: + r = n; + g = wh; + b = v; + break; + case 5: + r = v; + g = wh; + b = n; + break; + } + /* eslint-enable max-statements-per-line,no-multi-spaces */ + + return [r * 255, g * 255, b * 255, a]; +}; + +const rgbaTupleToHslaColor = (rgb: ColorTuple): HslaColor => { + const r = rgb[0] / 255; + const g = rgb[1] / 255; + const b = rgb[2] / 255; + const a = rgb[3] ?? 1; + const min = Math.min(r, g, b); + const max = Math.max(r, g, b); + const delta = max - min; + let h; + let s; + + if (max === min) { + h = 0; + } else if (r === max) { + h = (g - b) / delta; + } else if (g === max) { + h = 2 + (b - r) / delta; + } else if (b === max) { + h = 4 + (r - g) / delta; + } + + // @ts-ignore + h = Math.min(h * 60, 360); + + if (h < 0) { + h += 360; + } + + const l = (min + max) / 2; + + if (max === min) { + s = 0; + } else if (l <= 0.5) { + s = delta / (max + min); + } else { + s = delta / (2 - max - min); + } + + return { h: Math.floor(h), s: Math.floor(s * 100), l: Math.floor(l * 100), a }; +}; + +const hwbTupleToHslaColor = (hwb: ColorTuple): HslaColor => { + return rgbaTupleToHslaColor(hwbTupleToRgbTuple(hwb)); +}; + +const hslaColorToHslaString = ({ h, s, l, a }: HslaColor): HslaColorString => { + return `hsla(${h}, ${s}%, ${l}%, ${a ?? 1})` as HslaColorString; +}; + +const parse = (str: string): ParsedResult => { + const prefix = str.substr(0, 3).toLowerCase(); + let res; + if (prefix === 'hsl') { + res = { model: 'hsl', value: parseHsl(str) }; + } else if (prefix === 'hwb') { + res = { model: 'hwb', value: parseHwb(str) }; + } else { + res = { model: 'rgb', value: parseRgb(str) }; + } + if (!res || !res.value) { + throw new Error(`Clerk: "${str}" cannot be used as a color within 'variables'. You can pass one of: +- any valid hsl or hsla color +- any valid rgb or rgba color +- any valid hex color +- any valid hwb color +- ${Object.keys(keywords).join(', ')} +`); + } + return res as ParsedResult; +}; + +const toHslaColor = (str: string): HslaColor => { + const { model, value } = parse(str); + switch (model) { + case 'hsl': + return hslaTupleToHslaColor(value); + case 'hwb': + return hwbTupleToHslaColor(value); + case 'rgb': + return rgbaTupleToHslaColor(value); + } +}; + +const toHslaString = (hsla: HslaColor | string): HslaColorString => { + return typeof hsla === 'string' ? hslaColorToHslaString(toHslaColor(hsla)) : hslaColorToHslaString(hsla); +}; + +const changeHslaLightness = (color: HslaColor, num: number): HslaColor => { + return { ...color, l: color.l + num }; +}; + +const setHslaAlpha = (color: HslaColor, num: number): HslaColor => { + return { ...color, a: num }; +}; + +const changeHslaAlpha = (color: HslaColor, num: number): HslaColor => { + return { ...color, a: color.a ? color.a - num : undefined }; +}; + +const lighten = (color: string | undefined, percentage = 0): string | undefined => { + if (!color) { + return undefined; + } + const hsla = toHslaColor(color); + return toHslaString(changeHslaLightness(hsla, hsla.l * percentage)); +}; + +const makeSolid = (color: string | undefined): string | undefined => { + if (!color) { + return undefined; + } + return toHslaString({ ...toHslaColor(color), a: 1 }); +}; + +const makeTransparent = (color: string | undefined, percentage = 0): string | undefined => { + if (!color || color.toString() === '') { + return undefined; + } + const hsla = toHslaColor(color); + return toHslaString(changeHslaAlpha(hsla, (hsla.a ?? 1) * percentage)); +}; + +const setAlpha = (color: string, alpha: number) => { + if (!color.toString()) { + return color; + } + return toHslaString(setHslaAlpha(toHslaColor(color), alpha)); +}; + +export const colors = { + toHslaColor, + toHslaString, + changeHslaLightness, + setHslaAlpha, + lighten, + makeTransparent, + makeSolid, + setAlpha, +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/containsAllOf.ts b/packages/clerk-js/src/ui-retheme/utils/containsAllOf.ts new file mode 100644 index 0000000000..3a46e1ce07 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/containsAllOf.ts @@ -0,0 +1,7 @@ +/** + * Enforces that an array contains ALL keys of T + */ +export const containsAllOfType = + () => + >(array: U & ([T] extends [U[number]] ? unknown : 'Invalid')) => + array; diff --git a/packages/clerk-js/src/ui-retheme/utils/createCustomPages.tsx b/packages/clerk-js/src/ui-retheme/utils/createCustomPages.tsx new file mode 100644 index 0000000000..cff378eaf3 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/createCustomPages.tsx @@ -0,0 +1,305 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; +import type { CustomPage } from '@clerk/types'; + +import { isValidUrl } from '../../utils'; +import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID, USER_PROFILE_NAVBAR_ROUTE_ID } from '../constants'; +import type { NavbarRoute } from '../elements'; +import { CogFilled, TickShield, User } from '../icons'; +import { localizationKeys } from '../localization'; +import { ExternalElementMounter } from './ExternalElementMounter'; + +export type CustomPageContent = { + url: string; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type ProfileReorderItem = { + label: 'account' | 'security' | 'members' | 'settings'; +}; + +type ProfileCustomPage = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type ProfileCustomLink = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; +}; + +type GetDefaultRoutesReturnType = { + INITIAL_ROUTES: NavbarRoute[]; + pageToRootNavbarRouteMap: Record; + validReorderItemLabels: string[]; +}; + +type CreateCustomPagesParams = { + customPages: CustomPage[]; + getDefaultRoutes: () => GetDefaultRoutesReturnType; + setFirstPathToRoot: (routes: NavbarRoute[]) => NavbarRoute[]; + excludedPathsFromDuplicateWarning: string[]; +}; + +export const createUserProfileCustomPages = (customPages: CustomPage[]) => { + return createCustomPages({ + customPages, + getDefaultRoutes: getUserProfileDefaultRoutes, + setFirstPathToRoot: setFirstPathToUserProfileRoot, + excludedPathsFromDuplicateWarning: ['/', 'account'], + }); +}; + +export const createOrganizationProfileCustomPages = (customPages: CustomPage[]) => { + return createCustomPages({ + customPages, + getDefaultRoutes: getOrganizationProfileDefaultRoutes, + setFirstPathToRoot: setFirstPathToOrganizationProfileRoot, + excludedPathsFromDuplicateWarning: [], + }); +}; + +const createCustomPages = ({ + customPages, + getDefaultRoutes, + setFirstPathToRoot, + excludedPathsFromDuplicateWarning, +}: CreateCustomPagesParams) => { + const { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels } = getDefaultRoutes(); + + if (isDevelopmentEnvironment()) { + checkForDuplicateUsageOfReorderingItems(customPages, validReorderItemLabels); + } + + const validCustomPages = customPages.filter(cp => { + if (!isValidPageItem(cp, validReorderItemLabels)) { + if (isDevelopmentEnvironment()) { + console.error('Clerk: Invalid custom page data: ', cp); + } + return false; + } + return true; + }); + + const { allRoutes, contents } = getRoutesAndContents({ + customPages: validCustomPages, + defaultRoutes: INITIAL_ROUTES, + }); + + assertExternalLinkAsRoot(allRoutes); + + const routes = setFirstPathToRoot(allRoutes); + + if (isDevelopmentEnvironment()) { + warnForDuplicatePaths(routes, excludedPathsFromDuplicateWarning); + } + + return { + routes, + contents, + pageToRootNavbarRouteMap, + }; +}; + +type GetRoutesAndContentsParams = { + customPages: CustomPage[]; + defaultRoutes: NavbarRoute[]; +}; + +const getRoutesAndContents = ({ customPages, defaultRoutes }: GetRoutesAndContentsParams) => { + let remainingDefaultRoutes: NavbarRoute[] = defaultRoutes.map(r => r); + const contents: CustomPageContent[] = []; + + const routesWithoutDefaults: NavbarRoute[] = customPages.map((cp, index) => { + if (isCustomLink(cp)) { + return { + name: cp.label, + id: `custom-page-${index}`, + icon: props => ( + + ), + path: sanitizeCustomLinkURL(cp.url), + external: true, + }; + } + if (isCustomPage(cp)) { + const pageURL = sanitizeCustomPageURL(cp.url); + contents.push({ url: pageURL, mount: cp.mount, unmount: cp.unmount }); + return { + name: cp.label, + id: `custom-page-${index}`, + icon: props => ( + + ), + path: pageURL, + }; + } + const reorderItem = defaultRoutes.find(r => r.id === cp.label) as NavbarRoute; + remainingDefaultRoutes = remainingDefaultRoutes.filter(({ id }) => id !== cp.label); + return { ...reorderItem }; + }); + + const allRoutes = [...remainingDefaultRoutes, ...routesWithoutDefaults]; + + return { allRoutes, contents }; +}; + +// Set the path of the first route to '/' or if the first route is account or security, set the path of both account and security to '/' +const setFirstPathToUserProfileRoot = (routes: NavbarRoute[]): NavbarRoute[] => { + if (routes[0].id === 'account' || routes[0].id === 'security') { + return routes.map(r => { + if (r.id === 'account' || r.id === 'security') { + return { ...r, path: '/' }; + } + return r; + }); + } else { + return routes.map((r, index) => (index === 0 ? { ...r, path: '/' } : r)); + } +}; + +const setFirstPathToOrganizationProfileRoot = (routes: NavbarRoute[]): NavbarRoute[] => { + return routes.map((r, index) => (index === 0 ? { ...r, path: '/' } : r)); +}; + +const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[], validReorderItems: string[]) => { + const reorderItems = customPages.filter(cp => isReorderItem(cp, validReorderItems)); + reorderItems.reduce((acc, cp) => { + if (acc.includes(cp.label)) { + console.error( + `Clerk: The "${cp.label}" item is used more than once when reordering pages. This may cause unexpected behavior.`, + ); + } + return [...acc, cp.label]; + }, [] as string[]); +}; + +//path !== '/' && path !== 'account' +const warnForDuplicatePaths = (routes: NavbarRoute[], pathsToFilter: string[]) => { + const paths = routes + .filter(({ external, path }) => !external && pathsToFilter.every(p => p !== path)) + .map(({ path }) => path); + const duplicatePaths = paths.filter((p, index) => paths.indexOf(p) !== index); + duplicatePaths.forEach(p => { + console.error(`Clerk: Duplicate path "${p}" found in custom pages. This may cause unexpected behavior.`); + }); +}; + +const isValidPageItem = (cp: CustomPage, validReorderItems: string[]): cp is CustomPage => { + return isCustomPage(cp) || isCustomLink(cp) || isReorderItem(cp, validReorderItems); +}; + +const isCustomPage = (cp: CustomPage): cp is ProfileCustomPage => { + return !!cp.url && !!cp.label && !!cp.mount && !!cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isCustomLink = (cp: CustomPage): cp is ProfileCustomLink => { + return !!cp.url && !!cp.label && !cp.mount && !cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isReorderItem = (cp: CustomPage, validItems: string[]): cp is ProfileReorderItem => { + return ( + !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && validItems.some(v => v === cp.label) + ); +}; + +const sanitizeCustomPageURL = (url: string): string => { + if (!url) { + throw new Error('Clerk: URL is required for custom pages'); + } + if (isValidUrl(url)) { + throw new Error('Clerk: Absolute URLs are not supported for custom pages'); + } + return (url as string).charAt(0) === '/' && (url as string).length > 1 ? (url as string).substring(1) : url; +}; + +const sanitizeCustomLinkURL = (url: string): string => { + if (!url) { + throw new Error('Clerk: URL is required for custom links'); + } + if (isValidUrl(url)) { + return url; + } + return (url as string).charAt(0) === '/' ? url : `/${url}`; +}; + +const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => { + if (routes[0].external) { + throw new Error('Clerk: The first route cannot be a custom external link component'); + } +}; + +const getUserProfileDefaultRoutes = (): GetDefaultRoutesReturnType => { + const INITIAL_ROUTES: NavbarRoute[] = [ + { + name: localizationKeys('userProfile.start.headerTitle__account'), + id: USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT, + icon: User, + path: 'account', + }, + { + name: localizationKeys('userProfile.start.headerTitle__security'), + id: USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY, + icon: TickShield, + path: 'account', + }, + ]; + + const pageToRootNavbarRouteMap: Record = { + profile: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'email-address': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'phone-number': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'connected-account': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'web3-wallet': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + username: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'multi-factor': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY) as NavbarRoute, + password: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY) as NavbarRoute, + }; + + const validReorderItemLabels: string[] = INITIAL_ROUTES.map(r => r.id); + + return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels }; +}; + +const getOrganizationProfileDefaultRoutes = (): GetDefaultRoutesReturnType => { + const INITIAL_ROUTES: NavbarRoute[] = [ + { + name: localizationKeys('organizationProfile.start.headerTitle__members'), + id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS, + icon: User, + path: 'organization-members', + }, + { + name: localizationKeys('organizationProfile.start.headerTitle__settings'), + id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS, + icon: CogFilled, + path: 'organization-settings', + }, + ]; + + const pageToRootNavbarRouteMap: Record = { + 'invite-members': INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS) as NavbarRoute, + domain: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + profile: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + leave: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + delete: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + }; + + const validReorderItemLabels: string[] = INITIAL_ROUTES.map(r => r.id); + + return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels }; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/createInfiniteAccessProxy.ts b/packages/clerk-js/src/ui-retheme/utils/createInfiniteAccessProxy.ts new file mode 100644 index 0000000000..5b6db71d7e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/createInfiniteAccessProxy.ts @@ -0,0 +1,27 @@ +/** + * This function leverages JS Proxies to create an object that + * can be used as a placeholder when you want to run a callback + * but you don't have the necessary arguments ready yet. + * + * This proxy recursively returns itself so it can be passed safely + * when you don't know how the caller will try to access it. + * + * Eg: + * `const userCallback = (theme) => theme.prop1.prop2.prop3.replace();` + * + * Calling `userCallback` as follows is safe: + * `userCallback(createInfiniteAccessProxy())` + */ +export const createInfiniteAccessProxy = () => { + const get: ProxyHandler['get'] = (_, prop) => { + if (prop === Symbol.toPrimitive) { + return () => ''; + } else if (prop in Object.getPrototypeOf('')) { + return (args: unknown) => Object.getPrototypeOf('')[prop].call('', args); + } + return prop === Symbol.toPrimitive ? () => '' : proxy; + }; + + const proxy: any = new Proxy({}, { get }); + return proxy; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/createSlug.ts b/packages/clerk-js/src/ui-retheme/utils/createSlug.ts new file mode 100644 index 0000000000..15c9eaf763 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/createSlug.ts @@ -0,0 +1,5 @@ +export const createSlug = (str: string): string => { + const trimmedStr = str.trim().toLowerCase(); + const slug = trimmedStr.replace(/[^a-z0-9]+/g, '-'); + return slug; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/errorHandler.ts b/packages/clerk-js/src/ui-retheme/utils/errorHandler.ts new file mode 100644 index 0000000000..9864611c52 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/errorHandler.ts @@ -0,0 +1,148 @@ +import { snakeToCamel } from '@clerk/shared'; +import type { ClerkAPIError, ClerkRuntimeError } from '@clerk/types'; + +import { + isClerkAPIResponseError, + isClerkRuntimeError, + isKnownError, + isMetamaskError, +} from '../../core/resources/internal'; +import type { FormControlState } from './useFormControl'; + +interface ParserErrors { + fieldErrors: ClerkAPIError[]; + globalErrors: ClerkAPIError[]; +} + +const getFirstError = (err: ClerkAPIError[]) => err[0]; + +function setFieldErrors(fieldStates: Array>, errors: ClerkAPIError[]) { + if (!errors || errors.length < 1) { + return; + } + + fieldStates.forEach(field => { + let buildErrorMessage = field?.buildErrorMessage; + + if (!buildErrorMessage) { + buildErrorMessage = getFirstError; + } + + const errorsArray = errors.filter(err => { + return err.meta!.paramName === field.id || snakeToCamel(err.meta!.paramName) === field.id; + }); + + field.setError(errorsArray.length ? buildErrorMessage(errorsArray) : undefined); + }); +} + +function parseErrors(errors: ClerkAPIError[]): ParserErrors { + return (errors || []).reduce( + (memo, err) => { + if (err.meta!.paramName) { + memo.fieldErrors.push(err); + } else { + memo.globalErrors.push(err); + } + return memo; + }, + { + fieldErrors: Array(0), + globalErrors: Array(0), + }, + ); +} + +type HandleError = { + ( + err: Error, + fieldStates: Array>, + setGlobalError?: (err: ClerkRuntimeError | ClerkAPIError | string | undefined) => void, + ): void; +}; + +export const handleError: HandleError = (err, fieldStates, setGlobalError) => { + // Throw unknown errors + if (!isKnownError(err)) { + throw err; + } + + if (isMetamaskError(err)) { + return handleMetamaskError(err, fieldStates, setGlobalError); + } + + if (isClerkAPIResponseError(err)) { + return handleClerkApiError(err, fieldStates, setGlobalError); + } + + if (isClerkRuntimeError(err)) { + return handleClerkRuntimeError(err, fieldStates, setGlobalError); + } +}; + +// Returns the first global API error or undefined if none exists. +export function getGlobalError(err: Error): ClerkAPIError | undefined { + if (!isClerkAPIResponseError(err)) { + return; + } + const { globalErrors } = parseErrors(err.errors); + if (!globalErrors.length) { + return; + } + return globalErrors[0]; +} + +// Returns the first field API error or undefined if none exists. +export function getFieldError(err: Error): ClerkAPIError | undefined { + if (!isClerkAPIResponseError(err)) { + return; + } + const { fieldErrors } = parseErrors(err.errors); + + if (!fieldErrors.length) { + return; + } + + return fieldErrors[0]; +} + +export function getClerkAPIErrorMessage(err: ClerkAPIError): string { + return err.longMessage || err.message; +} + +const handleMetamaskError: HandleError = (err, _, setGlobalError) => { + return setGlobalError?.(err.message); +}; + +const handleClerkApiError: HandleError = (err, fieldStates, setGlobalError) => { + if (!isClerkAPIResponseError(err)) { + return; + } + + const { fieldErrors, globalErrors } = parseErrors(err.errors); + setFieldErrors(fieldStates, fieldErrors); + + if (setGlobalError) { + setGlobalError(undefined); + // Show only the first global error until we have snack bar stacks if applicable + // TODO: Make global errors localizable + const firstGlobalError = globalErrors[0]; + if (firstGlobalError) { + setGlobalError(firstGlobalError); + } + } +}; + +const handleClerkRuntimeError: HandleError = (err, _, setGlobalError) => { + if (!isClerkRuntimeError(err)) { + return; + } + + if (setGlobalError) { + setGlobalError(undefined); + const firstGlobalError = err; + if (firstGlobalError) { + setGlobalError(firstGlobalError); + } + } +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/factorSorting.ts b/packages/clerk-js/src/ui-retheme/utils/factorSorting.ts new file mode 100644 index 0000000000..d2569d10df --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/factorSorting.ts @@ -0,0 +1,50 @@ +import type { SignInFactor, SignInStrategy } from '@clerk/types'; + +const makeSortingOrderMap = (arr: T[]): Record => + arr.reduce((acc, k, i) => { + acc[k] = i; + return acc; + }, {} as Record); + +const STRATEGY_SORT_ORDER_PASSWORD_PREF = makeSortingOrderMap([ + 'password', + 'email_link', + 'email_code', + 'phone_code', +] as SignInStrategy[]); + +const STRATEGY_SORT_ORDER_OTP_PREF = makeSortingOrderMap([ + 'email_link', + 'email_code', + 'phone_code', + 'password', +] as SignInStrategy[]); + +const STRATEGY_SORT_ORDER_ALL_STRATEGIES_BUTTONS = makeSortingOrderMap([ + 'email_link', + 'email_code', + 'phone_code', + 'password', +] as SignInStrategy[]); + +const STRATEGY_SORT_ORDER_BACKUP_CODE_PREF = makeSortingOrderMap([ + 'totp', + 'phone_code', + 'backup_code', +] as SignInStrategy[]); + +const makeSortingFunction = + (sortingMap: Record) => + (a: SignInFactor, b: SignInFactor): number => { + const orderA = sortingMap[a.strategy]; + const orderB = sortingMap[b.strategy]; + if (orderA === undefined || orderB === undefined) { + return 0; + } + return orderA - orderB; + }; + +export const passwordPrefFactorComparator = makeSortingFunction(STRATEGY_SORT_ORDER_PASSWORD_PREF); +export const otpPrefFactorComparator = makeSortingFunction(STRATEGY_SORT_ORDER_OTP_PREF); +export const backupCodePrefFactorComparator = makeSortingFunction(STRATEGY_SORT_ORDER_BACKUP_CODE_PREF); +export const allStrategiesButtonsComparator = makeSortingFunction(STRATEGY_SORT_ORDER_ALL_STRATEGIES_BUTTONS); diff --git a/packages/clerk-js/src/ui-retheme/utils/fastDeepMerge.ts b/packages/clerk-js/src/ui-retheme/utils/fastDeepMerge.ts new file mode 100644 index 0000000000..007eb4cc49 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/fastDeepMerge.ts @@ -0,0 +1,44 @@ +/** + * Merges 2 objects without creating new object references + * The merged props will appear on the `target` object + * If `target` already has a value for a given key it will not be overwritten + */ +export const fastDeepMergeAndReplace = ( + source: Record | undefined | null, + target: Record | undefined | null, +) => { + if (!source || !target) { + return; + } + + for (const key in source) { + if (source[key] !== null && typeof source[key] === `object`) { + if (target[key] === undefined) { + target[key] = new (Object.getPrototypeOf(source[key]).constructor)(); + } + fastDeepMergeAndReplace(source[key], target[key]); + } else { + target[key] = source[key]; + } + } +}; + +export const fastDeepMergeAndKeep = ( + source: Record | undefined | null, + target: Record | undefined | null, +) => { + if (!source || !target) { + return; + } + + for (const key in source) { + if (source[key] !== null && typeof source[key] === `object`) { + if (target[key] === undefined) { + target[key] = new (Object.getPrototypeOf(source[key]).constructor)(); + } + fastDeepMergeAndKeep(source[key], target[key]); + } else if (target[key] === undefined) { + target[key] = source[key]; + } + } +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/formatSafeIdentifier.test.ts b/packages/clerk-js/src/ui-retheme/utils/formatSafeIdentifier.test.ts new file mode 100644 index 0000000000..15f57a6544 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/formatSafeIdentifier.test.ts @@ -0,0 +1,16 @@ +import { formatSafeIdentifier } from './formatSafeIdentifier'; + +describe('formatSafeIdentifier', () => { + const cases = [ + ['hello@example.com', 'hello@example.com'], + ['h***@***.com', 'h***@***.com'], + ['username', 'username'], + ['u***e', 'u***e'], + ['+71111111111', '+7 111 111-11-11'], + ['+791*******1', '+791*******1'], + ]; + + it.each(cases)('formats the safe identifier', (str, expected) => { + expect(formatSafeIdentifier(str)).toBe(expected); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/utils/formatSafeIdentifier.ts b/packages/clerk-js/src/ui-retheme/utils/formatSafeIdentifier.ts new file mode 100644 index 0000000000..2684f22f6f --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/formatSafeIdentifier.ts @@ -0,0 +1,15 @@ +import { stringToFormattedPhoneString } from './phoneUtils'; + +export const isMaskedIdentifier = (str: string | undefined | null) => str && str.includes('**'); + +/** + * Formats a string that can contain an email, a username or a phone number. + * Depending on the scenario, the string might be obfuscated (parts of the identifier replaced with "*") + * Refer to the tests for examples. + */ +export const formatSafeIdentifier = (str: string | undefined | null) => { + if (!str || str.includes('@') || isMaskedIdentifier(str) || str.match(/[a-zA-Z]/)) { + return str; + } + return stringToFormattedPhoneString(str); +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/fromEntries.ts b/packages/clerk-js/src/ui-retheme/utils/fromEntries.ts new file mode 100644 index 0000000000..0de89b5b2b --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/fromEntries.ts @@ -0,0 +1,6 @@ +export const fromEntries = (iterable: Iterable) => { + return [...iterable].reduce((obj, [key, val]) => { + obj[key] = val; + return obj; + }, {}); +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/getRelativeToNowDateKey.ts b/packages/clerk-js/src/ui-retheme/utils/getRelativeToNowDateKey.ts new file mode 100644 index 0000000000..93ca664e02 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/getRelativeToNowDateKey.ts @@ -0,0 +1,25 @@ +import { formatRelative } from '@clerk/shared'; + +import { localizationKeys } from '../localization'; + +export const getRelativeToNowDateKey = (date: Date) => { + const relativeDate = formatRelative({ date: date || new Date(), relativeTo: new Date() }); + if (!relativeDate) { + return ''; + } + switch (relativeDate.relativeDateCase) { + case 'previous6Days': + return localizationKeys('dates.previous6Days', { date: relativeDate.date }); + case 'lastDay': + return localizationKeys('dates.lastDay', { date: relativeDate.date }); + case 'sameDay': + return localizationKeys('dates.sameDay', { date: relativeDate.date }); + case 'nextDay': + return localizationKeys('dates.nextDay', { date: relativeDate.date }); + case 'next6Days': + return localizationKeys('dates.next6Days', { date: relativeDate.date }); + case 'other': + default: + return localizationKeys('dates.numeric', { date: relativeDate.date }); + } +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/getValidReactChildren.ts b/packages/clerk-js/src/ui-retheme/utils/getValidReactChildren.ts new file mode 100644 index 0000000000..edf8bae4c9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/getValidReactChildren.ts @@ -0,0 +1,5 @@ +import React from 'react'; + +export function getValidChildren(children: React.ReactNode) { + return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[]; +} diff --git a/packages/clerk-js/src/ui-retheme/utils/index.ts b/packages/clerk-js/src/ui-retheme/utils/index.ts new file mode 100644 index 0000000000..782bf87bcd --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/index.ts @@ -0,0 +1,24 @@ +export * from './fromEntries'; +export * from './containsAllOf'; +export * from './createInfiniteAccessProxy'; +export * from './fastDeepMerge'; +export * from './intl'; +export * from './colors'; +export * from './factorSorting'; +export * from './sleep'; +export * from './isMobileDevice'; +export * from './phoneUtils'; +export * from './formatSafeIdentifier'; +export * from './removeUndefinedProps'; +export * from './readObjectPath'; +export * from './useFormControl'; +export * from './errorHandler'; +export * from './range'; +export * from './getValidReactChildren'; +export * from './roleLocalizationKey'; +export * from './getRelativeToNowDateKey'; +export * from './mergeRefs'; +export * from './createSlug'; +export * from './passwordUtils'; +export * from './createCustomPages'; +export * from './ExternalElementMounter'; diff --git a/packages/clerk-js/src/ui-retheme/utils/intl.ts b/packages/clerk-js/src/ui-retheme/utils/intl.ts new file mode 100644 index 0000000000..93acf59ea1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/intl.ts @@ -0,0 +1,15 @@ +function supportedLocalesOf(locale?: string | string[]) { + if (!locale) { + return false; + } + const locales = Array.isArray(locale) ? locale : [locale]; + return (Intl as any).ListFormat.supportedLocalesOf(locales).length === locales.length; +} + +/** + * Intl.ListFormat was introduced in 2021 + * It is recommended to first check for browser support before using it + */ +export function canUseListFormat(locale: string | undefined) { + return 'ListFormat' in Intl && supportedLocalesOf(locale); +} diff --git a/packages/clerk-js/src/ui-retheme/utils/isMobileDevice.ts b/packages/clerk-js/src/ui-retheme/utils/isMobileDevice.ts new file mode 100644 index 0000000000..4523cab1c2 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/isMobileDevice.ts @@ -0,0 +1,13 @@ +const mobileNavigatorsRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; + +export const isMobileDevice = (): boolean => { + if (typeof window === 'undefined' || typeof window.document === 'undefined') { + return false; + } + + return !!( + window.matchMedia('only screen and (max-width: 760px)').matches || + mobileNavigatorsRegex.test(navigator.userAgent) || + ('ontouchstart' in document.documentElement && navigator.userAgent.match(/Mobi/)) + ); +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/mergeRefs.ts b/packages/clerk-js/src/ui-retheme/utils/mergeRefs.ts new file mode 100644 index 0000000000..bd48cbf471 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/mergeRefs.ts @@ -0,0 +1,10 @@ +export const mergeRefs = (...refs: React.RefObject[]) => { + return (node: any) => { + for (const _ref of refs) { + if (_ref) { + //@ts-expect-error + _ref.current = node; + } + } + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/passwordUtils.test.tsx b/packages/clerk-js/src/ui-retheme/utils/passwordUtils.test.tsx new file mode 100644 index 0000000000..82c87854f1 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/passwordUtils.test.tsx @@ -0,0 +1,225 @@ +import { bindCreateFixtures, renderHook } from '../../testUtils'; +import { OptionsProvider } from '../contexts'; +import { useLocalizations } from '../customizables'; +import { createPasswordError } from './passwordUtils'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('createPasswordError() constructs error that password', () => { + const createLocalizationConfig = t => ({ + t, + locale: 'en-US', + passwordSettings: { max_length: 72, min_length: 8 }, + }); + + it('is too short', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [{ code: 'form_password_length_too_short', message: '' }], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe('Your password must contain 8 or more characters.'); + }); + + it('is too short and needs an uppercase character. Shows only min_length error.', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [ + { code: 'form_password_length_too_short', message: '' }, + { code: 'form_password_no_uppercase', message: '' }, + ], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe('Your password must contain 8 or more characters.'); + }); + + it('needs an uppercase character', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [{ code: 'form_password_no_uppercase', message: '' }], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe('Your password must contain an uppercase letter.'); + }); + + it('is too short and needs an lowercase character. Shows only min_length error', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [ + { code: 'form_password_length_too_short', message: '' }, + { code: 'form_password_no_lowercase', message: '' }, + ], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe('Your password must contain 8 or more characters.'); + }); + + it('needs a lowercase and an uppercase character', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [ + { code: 'form_password_no_lowercase', message: '' }, + { code: 'form_password_no_uppercase', message: '' }, + ], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe('Your password must contain a lowercase letter and an uppercase letter.'); + }); + + it('needs a lowercase, an uppercase and a number', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [ + { code: 'form_password_no_number', message: '' }, + { code: 'form_password_no_lowercase', message: '' }, + { code: 'form_password_no_uppercase', message: '' }, + ], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe('Your password must contain a number, a lowercase letter, and an uppercase letter.'); + }); + + it('needs a lowercase, an uppercase and a number', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [ + { code: 'form_password_no_special_char', message: '' }, + { code: 'form_password_no_number', message: '' }, + { code: 'form_password_no_lowercase', message: '' }, + { code: 'form_password_no_uppercase', message: '' }, + ], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe( + 'Your password must contain a special character, a number, a lowercase letter, and an uppercase letter.', + ); + }); + + // + // zxcvbn + // + // + it('is not strong enough', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [ + { + code: 'form_password_not_strong_enough', + message: '', + meta: { + zxcvbn: { + suggestions: [{ code: 'anotherWord', message: '' }], + }, + }, + }, + ], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe('Your password is not strong enough. Add more words that are less common.'); + }); + + it('is not strong enough and has repeated characters', async () => { + const { wrapper: Wrapper } = await createFixtures(); + + const wrapperBefore = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper: wrapperBefore }); + + const res = createPasswordError( + [ + { + code: 'form_password_not_strong_enough', + message: '', + meta: { + zxcvbn: { + suggestions: [ + { code: 'anotherWord', message: '' }, + { code: 'repeated', message: '' }, + ], + }, + }, + }, + ], + createLocalizationConfig(result.current.t), + ); + expect(res).toBe( + 'Your password is not strong enough. Add more words that are less common. Avoid repeated words and characters.', + ); + }); +}); diff --git a/packages/clerk-js/src/ui-retheme/utils/passwordUtils.ts b/packages/clerk-js/src/ui-retheme/utils/passwordUtils.ts new file mode 100644 index 0000000000..34c3d765c9 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/passwordUtils.ts @@ -0,0 +1,90 @@ +import type { ClerkAPIError, PasswordSettingsData } from '@clerk/types'; + +import type { LocalizationKey } from '../localization'; +import { localizationKeys } from '../localization/localizationKeys'; +import { canUseListFormat } from '../utils'; + +// match FAPI error codes with localization keys +export const mapComplexityErrors = (passwordSettings: Pick) => { + return { + form_password_length_too_long: [ + 'unstable__errors.passwordComplexity.maximumLength', + 'length', + passwordSettings.max_length, + ], + form_password_length_too_short: [ + 'unstable__errors.passwordComplexity.minimumLength', + 'length', + passwordSettings.min_length, + ], + form_password_no_uppercase: 'unstable__errors.passwordComplexity.requireUppercase', + form_password_no_lowercase: 'unstable__errors.passwordComplexity.requireLowercase', + form_password_no_number: 'unstable__errors.passwordComplexity.requireNumbers', + form_password_no_special_char: 'unstable__errors.passwordComplexity.requireSpecialCharacter', + }; +}; + +type LocalizationConfigProps = { + t: (localizationKey: LocalizationKey | string | undefined) => string; + locale: string; + passwordSettings: Pick; +}; + +export const createPasswordError = (errors: ClerkAPIError[], localizationConfig: LocalizationConfigProps) => { + if (!localizationConfig) { + return errors[0].longMessage; + } + + const { t, locale, passwordSettings } = localizationConfig; + + if (errors?.[0]?.code === 'form_password_size_in_bytes_exceeded' || errors?.[0]?.code === 'form_password_pwned') { + return `${t(localizationKeys(`unstable__errors.${errors?.[0]?.code}` as any))}`; + } + + if (errors?.[0]?.code === 'form_password_not_strong_enough') { + const message = errors[0].meta?.zxcvbn?.suggestions + ?.map(s => { + return t(localizationKeys(`unstable__errors.zxcvbn.suggestions.${s.code}` as any)); + }) + .join(' '); + + return `${t(localizationKeys('unstable__errors.zxcvbn.notEnough'))} ${message}`; + } + + // show min length error first by itself + const minLenErrors = errors.filter(e => e.code === 'form_password_length_too_short'); + + const message = (minLenErrors.length ? minLenErrors : errors).map((s: any) => { + const localizedKey = (mapComplexityErrors(passwordSettings) as any)[s.code]; + + if (Array.isArray(localizedKey)) { + const [lk, attr, val] = localizedKey; + return t(localizationKeys(lk, { [attr]: val })); + } + return t(localizationKeys(localizedKey)); + }); + + const messageWithPrefix = createListFormat(message, locale); + + const passwordErrorMessage = addFullStop( + `${t(localizationKeys('unstable__errors.passwordComplexity.sentencePrefix'))} ${messageWithPrefix}`, + ); + + return passwordErrorMessage; +}; + +export const addFullStop = (string: string | undefined) => { + return !string ? '' : string.endsWith('.') ? string : `${string}.`; +}; + +export const createListFormat = (message: string[], locale: string) => { + let messageWithPrefix: string; + if (canUseListFormat(locale)) { + const formatter = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }); + messageWithPrefix = formatter.format(message); + } else { + messageWithPrefix = message.join(', '); + } + + return messageWithPrefix; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/phoneUtils.ts b/packages/clerk-js/src/ui-retheme/utils/phoneUtils.ts new file mode 100644 index 0000000000..25b565ea6d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/phoneUtils.ts @@ -0,0 +1,129 @@ +import type { CountryEntry, CountryIso } from '../elements/PhoneInput/countryCodeData'; +import { CodeToCountriesMap, IsoToCountryMap, SubAreaCodeSets } from '../elements/PhoneInput/countryCodeData'; + +// offset between uppercase ascii and regional indicator symbols +const OFFSET = 127397; +const emojiCache = {} as Record; +export function getFlagEmojiFromCountryIso(iso: CountryIso, fallbackIso = 'us'): string { + iso = iso || fallbackIso; + if (emojiCache[iso]) { + return emojiCache[iso]; + } + const codePoints = [...iso.toUpperCase()].map(c => c.codePointAt(0)! + OFFSET); + const res = String.fromCodePoint(...codePoints); + emojiCache[iso] = res; + return res; +} + +export function getCountryIsoFromFormattedNumber(formattedNumber: string, fallbackIso = 'us'): string { + const number = extractDigits(formattedNumber); + if (!number || number.length < 4) { + return fallbackIso; + } + + // Try to match US first based on subarea code + if (number.startsWith('1') && phoneNumberBelongsTo('us', number)) { + return 'us'; + } + + // Try to match CA first based on subarea code + if (number.startsWith('1') && phoneNumberBelongsTo('ca', number)) { + return 'ca'; + } + + // Otherwise, use the most specific code or fallback to US + return getCountryFromPhoneString(number).country.iso; +} + +export function formatPhoneNumber(phoneNumber: string, pattern: string | undefined, countryCode?: string): string { + if (!phoneNumber || !pattern) { + return phoneNumber; + } + + const digits = [...extractDigits(phoneNumber)].slice(0, maxE164CompliantLength(countryCode)); + + if (digits.length <= 3) { + return digits.join(''); + } + + let res = ''; + for (let i = 0; digits.length > 0; i++) { + if (i > pattern.length - 1) { + res += digits.shift(); + } else { + res += pattern[i] === '.' ? digits.shift() : pattern[i]; + } + } + return res; +} + +export function extractDigits(formattedPhone: string): string { + return (formattedPhone || '').replace(/[^\d]/g, ''); +} + +function phoneNumberBelongsTo(iso: 'us' | 'ca', phoneWithCode: string) { + if (!iso || !IsoToCountryMap.get(iso) || !phoneWithCode) { + return false; + } + + const code = phoneWithCode[0]; + const subArea = phoneWithCode.substring(1, 4); + return ( + code === IsoToCountryMap.get(iso)?.code && + phoneWithCode.length - 1 === maxDigitCountForPattern(IsoToCountryMap.get(iso)?.pattern || '') && + SubAreaCodeSets[iso].has(subArea) + ); +} + +function maxDigitCountForPattern(pattern: string) { + return (pattern.match(/\./g) || []).length; +} + +// https://en.wikipedia.org/wiki/E.164 +const MAX_PHONE_NUMBER_LENGTH = 15; +function maxE164CompliantLength(countryCode?: string) { + const usCountryCode = '1'; + countryCode = countryCode || usCountryCode; + const codeWithPrefix = countryCode.includes('+') ? countryCode : '+' + countryCode; + return MAX_PHONE_NUMBER_LENGTH - codeWithPrefix.length; +} + +export function parsePhoneString(str: string) { + const digits = extractDigits(str); + const iso = getCountryIsoFromFormattedNumber(digits) as CountryIso; + const pattern = IsoToCountryMap.get(iso)?.pattern || ''; + const code = IsoToCountryMap.get(iso)?.code || ''; + const number = digits.slice(code.length); + const formattedNumberWithCode = `+${code} ${formatPhoneNumber(number, pattern, code)}`; + return { iso, pattern, code, number, formattedNumberWithCode }; +} + +export function stringToFormattedPhoneString(str: string): string { + const parsed = parsePhoneString(str); + return `+${parsed.code} ${formatPhoneNumber(parsed.number, parsed.pattern, parsed.code)}`; +} + +export const byPriority = (a: CountryEntry, b: CountryEntry) => { + return b.priority - a.priority; +}; + +export function getCountryFromPhoneString(phone: string): { number: string; country: CountryEntry } { + const phoneWithCode = extractDigits(phone); + const matchingCountries = []; + + // Max country code length is 4. Try to match more specific codes first + for (const i of [4, 3, 2, 1]) { + const potentialCode = phoneWithCode.substring(0, i); + const countries = CodeToCountriesMap.get(potentialCode as any) || []; + + if (countries.length) { + matchingCountries.push(...countries); + } + } + + const fallbackCountry = IsoToCountryMap.get('us'); + const country: CountryEntry = matchingCountries.sort(byPriority)[0] || fallbackCountry; + const number = phoneWithCode.slice(country?.code.length || 0); + + return { number, country }; +} diff --git a/packages/clerk-js/src/ui-retheme/utils/range.ts b/packages/clerk-js/src/ui-retheme/utils/range.ts new file mode 100644 index 0000000000..5ef7ae1c5e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/range.ts @@ -0,0 +1 @@ +export const range = (min: number, max: number) => Array.from({ length: max - min + 1 }, (_, i) => min + i); diff --git a/packages/clerk-js/src/ui-retheme/utils/readObjectPath.ts b/packages/clerk-js/src/ui-retheme/utils/readObjectPath.ts new file mode 100644 index 0000000000..02fe67bace --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/readObjectPath.ts @@ -0,0 +1,11 @@ +export const readObjectPath = >(obj: O, path: string) => { + const props = (path || '').split('.'); + let cur = obj; + for (let i = 0; i < props.length; i++) { + cur = cur[props[i]]; + if (cur === undefined) { + return undefined; + } + } + return cur; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/removeUndefinedProps.ts b/packages/clerk-js/src/ui-retheme/utils/removeUndefinedProps.ts new file mode 100644 index 0000000000..d46f046079 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/removeUndefinedProps.ts @@ -0,0 +1,4 @@ +export const removeUndefinedProps = (obj: Record) => { + Object.keys(obj).forEach(key => obj[key] === undefined && delete obj[key]); + return obj; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/roleLocalizationKey.ts b/packages/clerk-js/src/ui-retheme/utils/roleLocalizationKey.ts new file mode 100644 index 0000000000..3e4d01930d --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/roleLocalizationKey.ts @@ -0,0 +1,24 @@ +import type { MembershipRole } from '@clerk/types'; + +import type { LocalizationKey } from '../localization/localizationKeys'; +import { localizationKeys } from '../localization/localizationKeys'; + +const roleToLocalizationKey: Record = { + basic_member: localizationKeys('membershipRole__basicMember'), + guest_member: localizationKeys('membershipRole__guestMember'), + admin: localizationKeys('membershipRole__admin'), +}; + +export const roleLocalizationKey = (role: MembershipRole | undefined): LocalizationKey | undefined => { + if (!role) { + return undefined; + } + return roleToLocalizationKey[role]; +}; + +export const customRoleLocalizationKey = (role: MembershipRole | undefined): LocalizationKey | undefined => { + if (!role) { + return undefined; + } + return localizationKeys(`roles.${role}`); +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/sleep.ts b/packages/clerk-js/src/ui-retheme/utils/sleep.ts new file mode 100644 index 0000000000..b35643128c --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/sleep.ts @@ -0,0 +1 @@ +export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); diff --git a/packages/clerk-js/src/ui-retheme/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui-retheme/utils/test/createFixtures.tsx new file mode 100644 index 0000000000..40418434aa --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/test/createFixtures.tsx @@ -0,0 +1,115 @@ +import type { ClerkOptions, ClientJSON, EnvironmentJSON, LoadedClerk } from '@clerk/types'; +import { jest } from '@jest/globals'; +import React from 'react'; + +import { default as ClerkCtor } from '../../../core/clerk'; +import { Client, Environment } from '../../../core/resources'; +import { ComponentContext, CoreClerkContextWrapper, EnvironmentProvider, OptionsProvider } from '../../contexts'; +import { AppearanceProvider } from '../../customizables'; +import { FlowMetadataProvider } from '../../elements'; +import { RouteContext } from '../../router'; +import { InternalThemeProvider } from '../../styledSystem'; +import { createClientFixtureHelpers, createEnvironmentFixtureHelpers } from './fixtureHelpers'; +import { createBaseClientJSON, createBaseEnvironmentJSON } from './fixtures'; +import { mockClerkMethods, mockRouteContextValue } from './mockHelpers'; + +type UnpackContext = NonNullable ? U : T>; + +const createInitialStateConfigParam = (baseEnvironment: EnvironmentJSON, baseClient: ClientJSON) => { + return { + ...createEnvironmentFixtureHelpers(baseEnvironment), + ...createClientFixtureHelpers(baseClient), + }; +}; + +type FParam = ReturnType; +type ConfigFn = (f: FParam) => void; + +export const bindCreateFixtures = ( + componentName: Parameters[0], + mockOpts?: { + router?: Parameters[0]; + }, +) => { + return { createFixtures: unboundCreateFixtures(componentName, mockOpts) }; +}; + +const unboundCreateFixtures = ['componentName']>( + componentName: N, + mockOpts?: { + router?: Parameters[0]; + }, +) => { + const createFixtures = async (...configFns: ConfigFn[]) => { + const baseEnvironment = createBaseEnvironmentJSON(); + const baseClient = createBaseClientJSON(); + configFns = configFns.filter(Boolean); + + if (configFns.length) { + const fParam = createInitialStateConfigParam(baseEnvironment, baseClient); + configFns.forEach(configFn => configFn(fParam)); + } + + const environmentMock = new Environment(baseEnvironment); + Environment.getInstance().fetch = jest.fn(() => Promise.resolve(environmentMock)); + + // @ts-expect-error + const clientMock = new Client(baseClient); + Client.getInstance().fetch = jest.fn(() => Promise.resolve(clientMock)); + + // Use a FAPI value for local production instances to avoid triggering the devInit flow during testing + const frontendApi = 'clerk.abcef.12345.prod.lclclerk.com'; + const tempClerk = new ClerkCtor(frontendApi); + await tempClerk.load(); + const clerkMock = mockClerkMethods(tempClerk as LoadedClerk); + const optionsMock = {} as ClerkOptions; + const routerMock = mockRouteContextValue(mockOpts?.router || {}); + + const fixtures = { + clerk: clerkMock, + signIn: clerkMock.client.signIn, + signUp: clerkMock.client.signUp, + environment: environmentMock, + router: routerMock, + options: optionsMock, + }; + + let componentContextProps: Partial & { componentName: N }>; + const props = { + setProps: (props: typeof componentContextProps) => { + componentContextProps = props; + }, + }; + + const MockClerkProvider = (props: any) => { + const { children } = props; + return ( + new Map() }} + > + + + + + + + + {children} + + + + + + + + + ); + }; + + return { wrapper: MockClerkProvider, fixtures, props }; + }; + createFixtures.config = (fn: ConfigFn) => fn; + return createFixtures; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/test/fixtureHelpers.ts b/packages/clerk-js/src/ui-retheme/utils/test/fixtureHelpers.ts new file mode 100644 index 0000000000..4fdaadc6b0 --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/test/fixtureHelpers.ts @@ -0,0 +1,431 @@ +import type { + ClientJSON, + DisplayConfigJSON, + EmailAddressJSON, + EnvironmentJSON, + ExternalAccountJSON, + OAuthProvider, + OrganizationEnrollmentMode, + PhoneNumberJSON, + PublicUserDataJSON, + SamlAccountJSON, + SessionJSON, + SignInJSON, + SignUpJSON, + UserJSON, + UserSettingsJSON, +} from '@clerk/types'; + +import type { OrgParams } from '../../../core/test/fixtures'; +import { createUser, getOrganizationId } from '../../../core/test/fixtures'; +import { createUserFixture } from './fixtures'; + +export const createEnvironmentFixtureHelpers = (baseEnvironment: EnvironmentJSON) => { + return { + ...createAuthConfigFixtureHelpers(baseEnvironment), + ...createDisplayConfigFixtureHelpers(baseEnvironment), + ...createOrganizationSettingsFixtureHelpers(baseEnvironment), + ...createUserSettingsFixtureHelpers(baseEnvironment), + }; +}; + +export const createClientFixtureHelpers = (baseClient: ClientJSON) => { + return { + ...createSignInFixtureHelpers(baseClient), + ...createSignUpFixtureHelpers(baseClient), + ...createUserFixtureHelpers(baseClient), + }; +}; + +const createUserFixtureHelpers = (baseClient: ClientJSON) => { + type WithUserParams = Omit< + Partial, + 'email_addresses' | 'phone_numbers' | 'external_accounts' | 'saml_accounts' | 'organization_memberships' + > & { + email_addresses?: Array>; + phone_numbers?: Array>; + external_accounts?: Array>; + saml_accounts?: Array>; + organization_memberships?: Array; + }; + + const createPublicUserData = (params: WithUserParams) => { + return { + first_name: 'FirstName', + last_name: 'LastName', + profile_image_url: '', + image_url: '', + identifier: 'email@test.com', + user_id: '', + ...params, + } as PublicUserDataJSON; + }; + + const withUser = (params: WithUserParams) => { + baseClient.sessions = baseClient.sessions || []; + + // set the first organization as active + let activeOrganization: string | null = null; + if (params?.organization_memberships?.length) { + activeOrganization = + typeof params.organization_memberships[0] === 'string' + ? params.organization_memberships[0] + : getOrganizationId(params.organization_memberships[0]); + } + + const session = { + status: 'active', + id: baseClient.sessions.length.toString(), + object: 'session', + last_active_organization_id: activeOrganization, + actor: null, + user: createUser(params), + public_user_data: createPublicUserData(params), + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + last_active_token: { + jwt: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NzU4NzY3OTAsImRhdGEiOiJmb29iYXIiLCJpYXQiOjE2NzU4NzY3MzB9.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg', + }, + } as SessionJSON; + baseClient.sessions.push(session); + }; + + return { withUser }; +}; + +const createSignInFixtureHelpers = (baseClient: ClientJSON) => { + type SignInWithEmailAddressParams = { + identifier?: string; + supportPassword?: boolean; + supportEmailCode?: boolean; + supportEmailLink?: boolean; + supportResetPassword?: boolean; + }; + + type SignInWithPhoneNumberParams = { + identifier?: string; + supportPassword?: boolean; + supportPhoneCode?: boolean; + supportResetPassword?: boolean; + }; + + type SignInFactorTwoParams = { + identifier?: string; + supportPhoneCode?: boolean; + supportTotp?: boolean; + supportBackupCode?: boolean; + supportResetPasswordEmail?: boolean; + supportResetPasswordPhone?: boolean; + }; + + const startSignInWithEmailAddress = (params?: SignInWithEmailAddressParams) => { + const { + identifier = 'hello@clerk.com', + supportPassword = true, + supportEmailCode, + supportEmailLink, + supportResetPassword, + } = params || {}; + baseClient.sign_in = { + status: 'needs_first_factor', + identifier, + supported_identifiers: ['email_address'], + supported_first_factors: [ + ...(supportPassword ? [{ strategy: 'password' }] : []), + ...(supportEmailCode ? [{ strategy: 'email_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportEmailLink ? [{ strategy: 'email_link', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportResetPassword + ? [ + { + strategy: 'reset_password_email_code', + safe_identifier: identifier || 'n*****@clerk.com', + emailAddressId: 'someEmailId', + }, + ] + : []), + ], + user_data: { ...(createUserFixture() as any) }, + } as SignInJSON; + }; + + const startSignInWithPhoneNumber = (params?: SignInWithPhoneNumberParams) => { + const { + identifier = '+301234567890', + supportPassword = true, + supportPhoneCode, + supportResetPassword, + } = params || {}; + baseClient.sign_in = { + status: 'needs_first_factor', + identifier, + supported_identifiers: ['phone_number'], + supported_first_factors: [ + ...(supportPassword ? [{ strategy: 'password' }] : []), + ...(supportPhoneCode ? [{ strategy: 'phone_code', safe_identifier: '+30********90' }] : []), + ...(supportResetPassword + ? [ + { + strategy: 'reset_password_phone_code', + safe_identifier: identifier || '+30********90', + phoneNumberId: 'someNumberId', + }, + ] + : []), + ], + user_data: { ...(createUserFixture() as any) }, + } as SignInJSON; + }; + + const startSignInFactorTwo = (params?: SignInFactorTwoParams) => { + const { + identifier = '+30 691 1111111', + supportPhoneCode = true, + supportTotp, + supportBackupCode, + supportResetPasswordEmail, + supportResetPasswordPhone, + } = params || {}; + baseClient.sign_in = { + status: 'needs_second_factor', + identifier, + ...(supportResetPasswordEmail + ? { + first_factor_verification: { + status: 'verified', + strategy: 'reset_password_email_code', + }, + } + : {}), + ...(supportResetPasswordPhone + ? { + first_factor_verification: { + status: 'verified', + strategy: 'reset_password_phone_code', + }, + } + : {}), + supported_identifiers: ['email_address', 'phone_number'], + supported_second_factors: [ + ...(supportPhoneCode ? [{ strategy: 'phone_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportTotp ? [{ strategy: 'totp', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ...(supportBackupCode ? [{ strategy: 'backup_code', safe_identifier: identifier || 'n*****@clerk.com' }] : []), + ], + user_data: { ...(createUserFixture() as any) }, + } as SignInJSON; + }; + + return { startSignInWithEmailAddress, startSignInWithPhoneNumber, startSignInFactorTwo }; +}; + +const createSignUpFixtureHelpers = (baseClient: ClientJSON) => { + type SignUpWithEmailAddressParams = { + emailAddress?: string; + supportEmailCode?: boolean; + supportEmailLink?: boolean; + }; + + type SignUpWithPhoneNumberParams = { + phoneNumber?: string; + }; + + const startSignUpWithEmailAddress = (params?: SignUpWithEmailAddressParams) => { + const { emailAddress = 'hello@clerk.com', supportEmailLink = true, supportEmailCode = true } = params || {}; + baseClient.sign_up = { + id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'missing_requirements', + email_address: emailAddress, + verifications: (supportEmailLink || supportEmailCode) && { + email_address: { + strategy: (supportEmailLink && 'email_link') || (supportEmailCode && 'email_code'), + }, + }, + } as SignUpJSON; + }; + + const startSignUpWithPhoneNumber = (params?: SignUpWithPhoneNumberParams) => { + const { phoneNumber = '+301234567890' } = params || {}; + baseClient.sign_up = { + id: 'sua_2HseAXFGN12eqlwARPMxyyUa9o9', + status: 'missing_requirements', + phone_number: phoneNumber, + } as SignUpJSON; + }; + + return { startSignUpWithEmailAddress, startSignUpWithPhoneNumber }; +}; + +const createAuthConfigFixtureHelpers = (environment: EnvironmentJSON) => { + const ac = environment.auth_config; + const withMultiSessionMode = () => { + // TODO: + ac.single_session_mode = false; + }; + return { withMultiSessionMode }; +}; + +const createDisplayConfigFixtureHelpers = (environment: EnvironmentJSON) => { + const dc = environment.display_config; + const withSupportEmail = (opts?: { email: string }) => { + dc.support_email = opts?.email || 'support@clerk.com'; + }; + const withoutClerkBranding = () => { + dc.branded = false; + }; + const withPreferredSignInStrategy = (opts: { strategy: DisplayConfigJSON['preferred_sign_in_strategy'] }) => { + dc.preferred_sign_in_strategy = opts.strategy; + }; + return { withSupportEmail, withoutClerkBranding, withPreferredSignInStrategy }; +}; + +const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) => { + const os = environment.organization_settings; + const withOrganizations = () => { + os.enabled = true; + }; + const withMaxAllowedMemberships = ({ max = 5 }) => { + os.max_allowed_memberships = max; + }; + + const withOrganizationDomains = (modes?: OrganizationEnrollmentMode[]) => { + os.domains.enabled = true; + os.domains.enrollment_modes = modes || ['automatic_invitation', 'automatic_invitation', 'manual_invitation']; + }; + return { withOrganizations, withMaxAllowedMemberships, withOrganizationDomains }; +}; + +const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => { + const us = environment.user_settings; + us.password_settings = { + allowed_special_characters: '', + disable_hibp: false, + min_length: 8, + max_length: 999, + require_special_char: false, + require_numbers: false, + require_uppercase: false, + require_lowercase: false, + show_zxcvbn: false, + min_zxcvbn_strength: 0, + }; + const emptyAttribute = { + first_factors: [], + second_factors: [], + verifications: [], + used_for_first_factor: false, + used_for_second_factor: false, + verify_at_sign_up: false, + }; + + const withPasswordComplexity = (opts?: Partial) => { + us.password_settings = { + ...us.password_settings, + ...opts, + }; + }; + + const withEmailAddress = (opts?: Partial) => { + us.attributes.email_address = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['email_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['email_code'], + verify_at_sign_up: true, + ...opts, + }; + }; + + const withEmailLink = () => { + withEmailAddress({ first_factors: ['email_link'], verifications: ['email_link'] }); + }; + + const withPhoneNumber = (opts?: Partial) => { + us.attributes.phone_number = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['phone_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['phone_code'], + verify_at_sign_up: true, + ...opts, + }; + }; + + const withUsername = (opts?: Partial) => { + us.attributes.username = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + ...opts, + }; + }; + + const withWeb3Wallet = (opts?: Partial) => { + us.attributes.web3_wallet = { + ...emptyAttribute, + enabled: true, + required: false, + used_for_first_factor: true, + first_factors: ['web3_metamask_signature'], + verifications: ['web3_metamask_signature'], + ...opts, + }; + }; + + const withName = (opts?: Partial) => { + const attr = { + ...emptyAttribute, + enabled: true, + required: false, + ...opts, + }; + us.attributes.first_name = attr; + us.attributes.last_name = attr; + }; + + const withPassword = (opts?: Partial) => { + us.attributes.password = { + ...emptyAttribute, + enabled: true, + required: false, + ...opts, + }; + }; + + const withSocialProvider = (opts: { provider: OAuthProvider; authenticatable?: boolean }) => { + const { authenticatable = true, provider } = opts || {}; + const strategy = 'oauth_' + provider; + // @ts-expect-error + us.social[strategy] = { + enabled: true, + authenticatable, + strategy: strategy, + }; + }; + + const withSaml = () => { + us.saml = { enabled: true }; + }; + + // TODO: Add the rest, consult pkg/generate/auth_config.go + + return { + withEmailAddress, + withEmailLink, + withPhoneNumber, + withUsername, + withWeb3Wallet, + withName, + withPassword, + withPasswordComplexity, + withSocialProvider, + withSaml, + }; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/test/fixtures.ts b/packages/clerk-js/src/ui-retheme/utils/test/fixtures.ts new file mode 100644 index 0000000000..78902b02af --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/test/fixtures.ts @@ -0,0 +1,210 @@ +import type { + AuthConfigJSON, + ClientJSON, + DisplayConfigJSON, + EnvironmentJSON, + OrganizationSettingsJSON, + PasswordSettingsData, + UserJSON, + UserSettingsJSON, +} from '@clerk/types'; + +import { containsAllOfType } from '../containsAllOf'; + +export const createBaseEnvironmentJSON = (): EnvironmentJSON => { + return { + id: 'env_1', + object: 'environment', + auth_config: createBaseAuthConfig(), + display_config: createBaseDisplayConfig(), + organization_settings: createBaseOrganizationSettings(), + user_settings: createBaseUserSettings(), + meta: { responseHeaders: { country: 'us' } }, + }; +}; + +const createBaseAuthConfig = (): AuthConfigJSON => { + return { + object: 'auth_config', + id: 'aac_1', + single_session_mode: true, + }; +}; + +const createBaseDisplayConfig = (): DisplayConfigJSON => { + return { + object: 'display_config', + id: 'display_config_1', + instance_environment_type: 'production', + application_name: 'TestApp', + theme: { + buttons: { + font_color: '#ffffff', + font_family: '"Inter", sans-serif', + font_weight: '600', + }, + general: { + color: '#6c47ff', + padding: '1em', + box_shadow: '0 2px 8px rgba(0, 0, 0, 0.2)', + font_color: '#151515', + font_family: '"Inter", sans-serif', + border_radius: '0.5em', + background_color: '#ffffff', + label_font_weight: '600', + }, + accounts: { + background_color: '#f2f2f2', + }, + }, + preferred_sign_in_strategy: 'password', + logo_url: 'https://images.clerk.com/uploaded/img_logo.png', + favicon_url: 'https://images.clerk.com/uploaded/img_favicon.png', + home_url: 'https://dashboard.clerk.com', + sign_in_url: 'https://dashboard.clerk.com/sign-in', + sign_up_url: 'https://dashboard.clerk.com/sign-up', + user_profile_url: 'https://accounts.clerk.com/user', + after_sign_in_url: 'https://dashboard.clerk.com', + after_sign_up_url: 'https://dashboard.clerk.com', + after_sign_out_one_url: 'https://accounts.clerk.com/sign-in/choose', + after_sign_out_all_url: 'https://dashboard.clerk.com/sign-in', + after_switch_session_url: 'https://dashboard.clerk.com', + organization_profile_url: 'https://accounts.clerk.com/organization', + create_organization_url: 'https://accounts.clerk.com/create-organization', + after_leave_organization_url: 'https://dashboard.clerk.com', + after_create_organization_url: 'https://dashboard.clerk.com', + support_email: '', + branded: true, + clerk_js_version: '4', + }; +}; + +const createBaseOrganizationSettings = (): OrganizationSettingsJSON => { + return { + enabled: false, + max_allowed_memberships: 5, + domains: { + enabled: false, + enrollment_modes: [], + }, + } as unknown as OrganizationSettingsJSON; +}; + +const attributes = Object.freeze( + containsAllOfType()([ + 'email_address', + 'phone_number', + 'username', + 'web3_wallet', + 'first_name', + 'last_name', + 'password', + 'authenticator_app', + 'backup_code', + ]), +); + +const socials = Object.freeze( + containsAllOfType()([ + 'oauth_facebook', + 'oauth_google', + 'oauth_hubspot', + 'oauth_github', + 'oauth_tiktok', + 'oauth_gitlab', + 'oauth_discord', + 'oauth_twitter', + 'oauth_twitch', + 'oauth_linkedin', + 'oauth_linkedin_oidc', + 'oauth_dropbox', + 'oauth_atlassian', + 'oauth_bitbucket', + 'oauth_microsoft', + 'oauth_notion', + 'oauth_apple', + 'oauth_line', + 'oauth_instagram', + 'oauth_coinbase', + 'oauth_spotify', + 'oauth_xero', + 'oauth_box', + 'oauth_slack', + 'oauth_linear', + ]), +); + +const createBaseUserSettings = (): UserSettingsJSON => { + const attributeConfig = Object.fromEntries( + attributes.map(attribute => [ + attribute, + { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + }, + ]), + ) as any as UserSettingsJSON['attributes']; + + const socialConfig = Object.fromEntries( + socials.map(social => [social, { enabled: false, required: false, authenticatable: false, strategy: social }]), + ) as any as UserSettingsJSON['social']; + + const passwordSettingsConfig = { + allowed_special_characters: '', + max_length: 0, + min_length: 8, + require_special_char: false, + require_numbers: false, + require_lowercase: false, + require_uppercase: false, + disable_hibp: true, + show_zxcvbn: false, + min_zxcvbn_strength: 0, + } as UserSettingsJSON['password_settings']; + + return { + attributes: { ...attributeConfig }, + actions: { delete_self: false, create_organization: false }, + social: { ...socialConfig }, + sign_in: { + second_factor: { + required: false, + }, + }, + sign_up: { + custom_action_required: false, + progressive: true, + captcha_enabled: false, + disable_hibp: false, + }, + restrictions: { + allowlist: { + enabled: false, + }, + blocklist: { + enabled: false, + }, + }, + password_settings: passwordSettingsConfig, + }; +}; + +export const createBaseClientJSON = (): ClientJSON => { + return {} as ClientJSON; +}; + +// TODO: +export const createUserFixture = (): UserJSON => { + return { + first_name: 'Firstname', + last_name: 'Lastname', + profile_image_url: 'https://lh3.googleusercontent.com/a/public-photo-kmmfZIb=s1000-c', + image_url: 'https://lh3.googleusercontent.com/a/public-photo-kmmfZIb=s1000-c', + } as UserJSON; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui-retheme/utils/test/mockHelpers.ts new file mode 100644 index 0000000000..8f4d5e38ac --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/test/mockHelpers.ts @@ -0,0 +1,73 @@ +import type { LoadedClerk } from '@clerk/types'; +import { jest } from '@jest/globals'; + +import type { RouteContextValue } from '../../router'; + +type FunctionLike = (...args: any) => any; + +type DeepJestMocked = T extends FunctionLike + ? jest.Mocked + : T extends object + ? { + [k in keyof T]: DeepJestMocked; + } + : T; + +const mockProp = (obj: T, k: keyof T) => { + if (typeof obj[k] === 'function') { + // @ts-ignore + obj[k] = jest.fn(); + } +}; + +const mockMethodsOf = | null = any>(obj: T, options?: { exclude: (keyof T)[] }) => { + if (!obj) { + return; + } + Object.keys(obj) + .filter(key => !options?.exclude.includes(key as keyof T)) + .forEach(k => mockProp(obj, k)); +}; + +export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked => { + mockMethodsOf(clerk); + mockMethodsOf(clerk.client.signIn); + mockMethodsOf(clerk.client.signUp); + clerk.client.sessions.forEach(session => { + mockMethodsOf(session, { + exclude: ['experimental__checkAuthorization'], + }); + mockMethodsOf(session.user); + session.user?.emailAddresses.forEach(m => mockMethodsOf(m)); + session.user?.phoneNumbers.forEach(m => mockMethodsOf(m)); + session.user?.externalAccounts.forEach(m => mockMethodsOf(m)); + session.user?.organizationMemberships.forEach(m => { + mockMethodsOf(m); + mockMethodsOf(m.organization); + }); + }); + mockProp(clerk, 'navigate'); + mockProp(clerk, 'setActive'); + mockProp(clerk, '__internal_navigateWithError'); + return clerk as any as DeepJestMocked; +}; + +export const mockRouteContextValue = ({ queryString = '' }: Partial>) => { + return { + basePath: '', + startPath: '', + flowStartPath: '', + fullPath: '', + indexPath: '', + currentPath: '', + queryString, + queryParams: {}, + getMatchData: jest.fn(), + matches: jest.fn(), + baseNavigate: jest.fn(), + navigate: jest.fn(), + resolve: jest.fn((to: string) => new URL(to, 'https://clerk.com')), + refresh: jest.fn(), + params: {}, + } as RouteContextValue; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/test/runFakeTimers.ts b/packages/clerk-js/src/ui-retheme/utils/test/runFakeTimers.ts new file mode 100644 index 0000000000..5fda44aeca --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/test/runFakeTimers.ts @@ -0,0 +1,35 @@ +import { jest } from '@jest/globals'; +import { act } from '@testing-library/react'; + +type WithAct = (fn: T) => T; +const withAct = ((fn: any) => + (...args: any) => { + act(() => { + fn(...args); + }); + }) as WithAct; + +const advanceTimersByTime = withAct(jest.advanceTimersByTime.bind(jest)); +const runAllTimers = withAct(jest.runAllTimers.bind(jest)); +const runOnlyPendingTimers = withAct(jest.runOnlyPendingTimers.bind(jest)); + +const createFakeTimersHelpers = () => { + return { advanceTimersByTime, runAllTimers, runOnlyPendingTimers }; +}; + +type FakeTimersHelpers = ReturnType; +type RunFakeTimersCallback = (timers: FakeTimersHelpers) => void | Promise; + +export const runFakeTimers = >( + cb: T, +): R extends Promise ? Promise : void => { + jest.useFakeTimers(); + const res = cb(createFakeTimersHelpers()); + if (res && 'then' in res) { + // @ts-expect-error + return res.finally(() => jest.useRealTimers()); + } + jest.useRealTimers(); + // @ts-ignore + return; +}; diff --git a/packages/clerk-js/src/ui-retheme/utils/useFormControl.ts b/packages/clerk-js/src/ui-retheme/utils/useFormControl.ts new file mode 100644 index 0000000000..52f8200e9e --- /dev/null +++ b/packages/clerk-js/src/ui-retheme/utils/useFormControl.ts @@ -0,0 +1,207 @@ +import type { ClerkAPIError } from '@clerk/types'; +import type { HTMLInputTypeAttribute } from 'react'; +import { useState } from 'react'; + +import { useDebounce } from '../hooks'; +import type { LocalizationKey } from '../localization'; +import { useLocalizations } from '../localization'; + +type SelectOption = { value: string; label?: string }; + +type Options = { + isRequired?: boolean; + placeholder?: string | LocalizationKey; + options?: SelectOption[]; + defaultChecked?: boolean; + infoText?: LocalizationKey | string; +} & ( + | { + label: string | LocalizationKey; + validatePassword?: never; + buildErrorMessage?: never; + type?: Exclude; + radioOptions?: never; + } + | { + label: string | LocalizationKey; + type: Extract; + validatePassword: boolean; + buildErrorMessage?: (err: ClerkAPIError[]) => ClerkAPIError | string | undefined; + radioOptions?: never; + } + | { + validatePassword?: never; + buildErrorMessage?: never; + type: Extract; + label?: string | LocalizationKey; + radioOptions: { + value: string; + label: string | LocalizationKey; + description?: string | LocalizationKey; + }[]; + } +); + +type FieldStateProps = { + id: Id; + name: Id; + value: string; + checked?: boolean; + onChange: React.ChangeEventHandler; + onBlur: React.FocusEventHandler; + onFocus: React.FocusEventHandler; + feedback: string; + feedbackType: FeedbackType; + setError: (error: string | ClerkAPIError | undefined) => void; + setWarning: (warning: string) => void; + setSuccess: (message: string) => void; + setInfo: (info: string) => void; + setHasPassedComplexity: (b: boolean) => void; + clearFeedback: () => void; + hasPassedComplexity: boolean; + isFocused: boolean; +} & Omit; + +export type FormControlState = FieldStateProps & { + setError: (error: string | ClerkAPIError | undefined) => void; + setSuccess: (message: string) => void; + setInfo: (info: string) => void; + setValue: (val: string | undefined) => void; + setChecked: (isChecked: boolean) => void; + clearFeedback: () => void; + props: FieldStateProps; +}; + +export type FeedbackType = 'success' | 'error' | 'warning' | 'info'; + +export const useFormControl = ( + id: Id, + initialState: string, + opts?: Options, +): FormControlState => { + opts = opts || { + type: 'text', + label: '', + isRequired: false, + placeholder: '', + options: [], + defaultChecked: false, + }; + + const { translateError, t } = useLocalizations(); + const [value, setValueInternal] = useState(initialState); + const [isFocused, setFocused] = useState(false); + const [checked, setCheckedInternal] = useState(opts?.defaultChecked || false); + const [hasPassedComplexity, setHasPassedComplexity] = useState(false); + const [feedback, setFeedback] = useState<{ message: string; type: FeedbackType }>({ + message: '', + type: 'info', + }); + + const onChange: FormControlState['onChange'] = event => { + if (opts?.type === 'checkbox') { + return setCheckedInternal(event.target.checked); + } + return setValueInternal(event.target.value || ''); + }; + + const setValue: FormControlState['setValue'] = val => setValueInternal(val || ''); + const setChecked: FormControlState['setChecked'] = checked => setCheckedInternal(checked); + const setError: FormControlState['setError'] = error => { + if (error) { + setFeedback({ message: translateError(error), type: 'error' }); + } + }; + const setSuccess: FormControlState['setSuccess'] = message => { + if (message) { + setFeedback({ message, type: 'success' }); + } + }; + + const setWarning: FormControlState['setWarning'] = warning => { + if (warning) { + setFeedback({ message: translateError(warning), type: 'warning' }); + } + }; + + const setInfo: FormControlState['setInfo'] = info => { + if (info) { + setFeedback({ message: info, type: 'info' }); + } + }; + + const clearFeedback: FormControlState['clearFeedback'] = () => { + setFeedback({ message: '', type: 'info' }); + }; + + const onFocus: FormControlState['onFocus'] = () => { + setFocused(true); + }; + + const onBlur: FormControlState['onBlur'] = () => { + setFocused(false); + }; + + const { defaultChecked, validatePassword: validatePasswordProp, buildErrorMessage, ...restOpts } = opts; + + const props = { + id, + name: id, + value, + checked, + setSuccess, + setError, + onChange, + onBlur, + onFocus, + setWarning, + feedback: feedback.message || t(opts.infoText), + feedbackType: feedback.type, + setInfo, + clearFeedback, + hasPassedComplexity, + setHasPassedComplexity, + validatePassword: opts.type === 'password' ? opts.validatePassword : undefined, + isFocused, + ...restOpts, + }; + + return { props, ...props, buildErrorMessage, setError, setValue, setChecked }; +}; + +type FormControlStateLike = Pick; + +export const buildRequest = (fieldStates: Array): Record => { + const request: { [x: string]: any } = {}; + fieldStates.forEach(x => { + request[x.id] = x.value; + }); + return request; +}; + +type DebouncedFeedback = { + debounced: { + feedback: string; + feedbackType: FeedbackType; + }; +}; + +type DebouncingOption = { + feedback?: string; + feedbackType?: FeedbackType; + isFocused?: boolean; + delayInMs?: number; +}; +export const useFormControlFeedback = (opts?: DebouncingOption): DebouncedFeedback => { + const { feedback = '', delayInMs = 100, feedbackType = 'info', isFocused = false } = opts || {}; + const shouldHide = isFocused ? false : ['info', 'warning'].includes(feedbackType); + + const debouncedState = useDebounce( + { feedback: shouldHide ? '' : feedback, feedbackType: shouldHide ? 'info' : feedbackType }, + delayInMs, + ); + + return { + debounced: debouncedState, + }; +}; diff --git a/packages/clerk-js/webpack.config.js b/packages/clerk-js/webpack.config.js index 54ad82f7dc..e364547353 100644 --- a/packages/clerk-js/webpack.config.js +++ b/packages/clerk-js/webpack.config.js @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ const webpack = require('webpack'); const packageJSON = require('./package.json'); const path = require('path'); @@ -26,12 +25,17 @@ const variantToSourceFile = { /** @returns { import('webpack').Configuration } */ const common = ({ mode }) => { + const uiRetheme = process.env.CLERK_UI_RETHEME === '1' || process.env.CLERK_UI_RETHEME === 'true'; + return { mode, resolve: { // Attempt to resolve these extensions in order // @see https://webpack.js.org/configuration/resolve/#resolveextensions extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'], + alias: { + ...(uiRetheme && { './ui': './ui-retheme' }), + }, }, plugins: [ new webpack.DefinePlugin({ @@ -238,7 +242,7 @@ const devConfig = ({ mode, env }) => { const variant = env.variant || variants.clerkBrowser; // accept an optional devOrigin environment option to change the origin of the dev server. // By default we use https://js.lclclerk.com which is what our local dev proxy looks for. - const devUrl = new URL(env.devOrigin || 'https://js.lclclerk.com'); + const devUrl = new URL(env.devOrigin || 'http://localhost:4000'); const commonForDev = () => { return {