diff --git a/.changeset/wizard-architecture-layers.md b/.changeset/wizard-architecture-layers.md new file mode 100644 index 00000000000..addae609573 --- /dev/null +++ b/.changeset/wizard-architecture-layers.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Refactor `<__experimental_ConfigureSSO />` into a layered primitive set: a state-driven Wizard, a UI-only Stepper, a `Step` compound, and ProfileCard chrome. No public component API change. Drops the central FooterActionsContext registry — each step now renders its own footer via `Step.Footer.Previous` / `Step.Footer.Continue` purely-presentational compounds. Adds a SelectProviderStep boilerplate filtered out of the breadcrumb. diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx index f1585e7a8de..dc1b82dd8d2 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx @@ -1,19 +1,19 @@ -import { __internal_useUserEnterpriseConnections, useOrganization, useUser } from '@clerk/shared/react'; +import { __internal_useUserEnterpriseConnections } from '@clerk/shared/react'; import type { __experimental_ConfigureSSOProps } from '@clerk/shared/types'; import React from 'react'; -import { useEnvironment, withCoreUserGuard } from '@/contexts'; -import { Box, Col, descriptors, Flex, Flow, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; -import { ApplicationLogo } from '@/elements/ApplicationLogo'; +import { withCoreUserGuard } from '@/contexts'; +import { Col, descriptors, Flow } from '@/customizables'; import { withCardStateProvider } from '@/elements/contexts'; -import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; import { ProfileCard } from '@/elements/ProfileCard'; -import { BoxIcon } from '@/icons'; import { Route, Switch } from '@/router'; import { ConfigureSSOFlowProvider } from './ConfigureSSOContext'; -import { ConfigureCreateApp, ConfirmationStep, ProvideEmail, TestConfigurationStep, VerifyDomainStep } from './steps'; -import { ConfigureSSOWizard } from './wizard'; +import { ConfigureSSOHeader } from './ConfigureSSOHeader'; +import { ConfigureSSONavbar } from './ConfigureSSONavbar'; +import { ConfigureSSOSkeleton } from './ConfigureSSOSkeleton'; +import { Wizard } from './elements/Wizard'; +import { ConfigureStep, ConfirmationStep, SelectProviderStep, TestConfigurationStep, VerifyDomainStep } from './steps'; const ConfigureSSOInternal = () => { return ( @@ -29,73 +29,12 @@ const ConfigureSSOInternal = () => { const AuthenticatedContent = withCoreUserGuard(() => { const contentRef = React.useRef(null); - const { applicationName, logoImageUrl } = useEnvironment().displayConfig; - const { organizationSettings } = useEnvironment(); - const { parsedOptions } = useAppearance(); - const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); - - const { data: enterpriseConnections, isLoading: isLoadingEnterpriseConnections } = - __internal_useUserEnterpriseConnections({ enabled: true }); - // Currently FAPI only supports one enterprise connection per user - const enterpriseConnection = enterpriseConnections?.[0]; return ( ({ display: 'grid', gridTemplateColumns: '1fr 3fr', height: t.sizes.$176, overflow: 'hidden' })} > - - ({ - gap: t.space.$2, - padding: `${t.space.$none} ${t.space.$3}`, - maxWidth: '100%', - })} - > - {hasLogo ? ( - ({ width: t.space.$9, height: t.space.$9, borderRadius: t.radii.$md, overflow: 'hidden' })} - /> - ) : ( - ({ - width: t.space.$9, - height: t.space.$9, - flexShrink: 0, - borderRadius: t.radii.$md, - backgroundColor: t.colors.$primary500, - color: t.colors.$colorPrimaryForeground, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - })} - aria-hidden - > - ({ width: t.sizes.$4, height: t.sizes.$4 })} - /> - - )} - - - - {applicationName} - - {organizationSettings.enabled && } - - - } - titleSx={t => ({ fontSize: t.fontSizes.$lg })} - title={localizationKeys('configureSSO.navbar.title')} - routes={[]} - contentRef={contentRef} - /> + { borderWidth: t.borderWidths.$normal, borderStyle: t.borderStyles.$solid, borderColor: t.colors.$borderAlpha150, - marginBlock: '-1px', - marginInlineEnd: '-1px', flex: 1, })} > - - - + - + ); }); -const ConfigureSSOSteps = () => { - const { user } = useUser(); +const ConfigureSSOCardContent = () => { + const { data: enterpriseConnections, isLoading } = __internal_useUserEnterpriseConnections({ enabled: true }); + // Currently FAPI only supports one enterprise connection per user + const enterpriseConnection = enterpriseConnections?.[0]; - const primaryEmailAddress = user?.primaryEmailAddress; + // Initial-load gate at root — wizard never sees isLoading + if (isLoading && !enterpriseConnection) { + return ; + } return ( - - - - {!primaryEmailAddress && ( - - - - )} - - - - - - - - {/* TODO: Implement configure steps */} - - - - - - - - - - - - - ); -}; + + + -const OrganizationSidebarSubtitle = () => { - const { organization } = useOrganization(); + + + - if (!organization) { - return null; - } + + + - return ( - ({ color: t.colors.$colorMutedForeground })} - > - {organization?.name} - + + + + + + + + + + + + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx index c182456d34e..9513ca41a5e 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOContext.tsx @@ -11,39 +11,28 @@ export interface ConfigureSSOData { enterpriseConnection: EnterpriseConnectionResource | undefined; } -export interface ConfigureSSOContextValue extends ConfigureSSOData { - /** - * `true` while the parent is still fetching the user's enterprise - * connection - */ - isLoading: boolean; -} - interface ConfigureSSOFlowProviderProps { enterpriseConnection: EnterpriseConnectionResource | undefined; - isLoading: boolean; } -const ConfigureSSOFlowContext = React.createContext(null); +const ConfigureSSOFlowContext = React.createContext(null); ConfigureSSOFlowContext.displayName = 'ConfigureSSOFlowContext'; export const ConfigureSSOFlowProvider = ({ enterpriseConnection, - isLoading, children, }: PropsWithChildren): JSX.Element => { - const value = React.useMemo( + const value = React.useMemo( () => ({ enterpriseConnection, - isLoading, }), - [enterpriseConnection, isLoading], + [enterpriseConnection], ); return {children}; }; -export const useConfigureSSOFlow = (): ConfigureSSOContextValue => { +export const useConfigureSSOFlow = (): ConfigureSSOData => { const ctx = React.useContext(ConfigureSSOFlowContext); if (!ctx) { throw new Error('useConfigureSSOFlow called outside .'); diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx new file mode 100644 index 00000000000..0630e0852d0 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx @@ -0,0 +1,63 @@ +import { Icon, Text } from '@/customizables'; +import { Check } from '@/icons'; + +import { ProfileCardHeader } from './elements/ProfileCard'; +import { Stepper } from './elements/Stepper'; +import { useWizard } from './elements/Wizard'; + +export const ConfigureSSOHeader = (): JSX.Element => { + const { activeSteps, currentStep, goToStep } = useWizard(); + // Select Provider isn't part of the visual breadcrumb per the design — + // filter it out here. The wizard still tracks it as the first step + // for navigation (goNext from it advances to verify-domain, Previous + // is naturally disabled because isFirstStep is true). + const visibleSteps = activeSteps.filter(step => step.id !== 'select-provider'); + const currentIndex = visibleSteps.findIndex(step => step.id === currentStep?.id); + + return ( + + + {visibleSteps.map((step, index) => { + const isCurrent = index === currentIndex; + const isCompleted = step.isCompleted ?? index < currentIndex; + const isReachable = isCompleted || index <= currentIndex; + const showCheck = isCompleted && !isCurrent; + + return ( + ({ width: theme.sizes.$2, height: theme.sizes.$2, color: theme.colors.$white })} + /> + ) : ( + ({ + fontSize: theme.fontSizes.$xs, + fontWeight: theme.fontWeights.$semibold, + color: theme.colors.$colorBackground, + })} + > + {index + 1} + + ) + } + isCurrent={isCurrent} + isCompleted={isCompleted} + isReachable={isReachable} + onClick={() => { + if (isReachable) { + void goToStep(step.id); + } + }} + /> + ); + })} + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx new file mode 100644 index 00000000000..f993aca4765 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSONavbar.tsx @@ -0,0 +1,98 @@ +import { useOrganization } from '@clerk/shared/react/index'; +import React from 'react'; + +import { useEnvironment } from '@/contexts'; +import { Box, Col, Flex, Icon, localizationKeys, Text, useAppearance } from '@/customizables'; +import { ApplicationLogo } from '@/elements/ApplicationLogo'; +import { NavBar, NavbarContextProvider } from '@/elements/Navbar'; +import { BoxIcon } from '@/icons'; + +type ConfigureSSONavbarProps = React.PropsWithChildren<{ + contentRef: React.RefObject; +}>; + +export const ConfigureSSONavbar = ({ children, contentRef }: ConfigureSSONavbarProps) => { + const { parsedOptions } = useAppearance(); + const { + organizationSettings, + displayConfig: { applicationName, logoImageUrl }, + } = useEnvironment(); + + const hasLogo = Boolean(parsedOptions.logoImageUrl || logoImageUrl); + + return ( + + ({ + gap: t.space.$2, + padding: `${t.space.$none} ${t.space.$3}`, + maxWidth: '100%', + })} + > + {hasLogo ? ( + ({ width: t.space.$9, height: t.space.$9, borderRadius: t.radii.$md, overflow: 'hidden' })} + /> + ) : ( + ({ + width: t.space.$9, + height: t.space.$9, + flexShrink: 0, + borderRadius: t.radii.$md, + backgroundColor: t.colors.$primary500, + color: t.colors.$colorPrimaryForeground, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + })} + aria-hidden + > + ({ width: t.sizes.$4, height: t.sizes.$4 })} + /> + + )} + + + + {applicationName} + + {organizationSettings.enabled && } + + + } + titleSx={t => ({ fontSize: t.fontSizes.$lg })} + title={localizationKeys('configureSSO.navbar.title')} + routes={[]} + contentRef={contentRef} + /> + {children} + + ); +}; + +const OrganizationSidebarSubtitle = (): JSX.Element | null => { + const { organization } = useOrganization(); + + if (!organization) { + return null; + } + + return ( + ({ color: t.colors.$colorMutedForeground })} + > + {organization?.name} + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOSkeleton.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOSkeleton.tsx new file mode 100644 index 00000000000..035e4d9ee74 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOSkeleton.tsx @@ -0,0 +1,35 @@ +import { descriptors, Flex, Spinner } from '@/customizables'; + +import { ProfileCardHeader } from './elements/ProfileCard'; +import { Step } from './elements/Step'; +import { Stepper } from './elements/Stepper'; + +export const ConfigureSSOSkeleton = () => { + return ( + <> + + + + + ({ + flex: 1, + padding: theme.space.$5, + })} + > + + + + + + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/elements/ProfileCard.tsx b/packages/ui/src/components/ConfigureSSO/elements/ProfileCard.tsx new file mode 100644 index 00000000000..7891ec2bbc0 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/ProfileCard.tsx @@ -0,0 +1,40 @@ +import { descriptors, Flex } from '@/customizables'; +import { common } from '@/styledSystem'; + +type ProfileCardHeaderProps = React.PropsWithChildren; + +export const ProfileCardHeader = (props: ProfileCardHeaderProps): JSX.Element => ( + ({ + gap: theme.space.$2, + padding: `${theme.space.$5}`, + borderBottomWidth: theme.borderWidths.$normal, + borderBottomStyle: theme.borderStyles.$solid, + borderBottomColor: theme.colors.$borderAlpha100, + })} + /> +); + +type ProfileCardFooterProps = React.PropsWithChildren; + +export const ProfileCardFooter = (props: ProfileCardFooterProps): JSX.Element => ( + ({ + gap: theme.space.$2, + padding: theme.space.$4, + minHeight: theme.sizes.$16, + background: common.mutedBackground(theme), + borderTopWidth: theme.borderWidths.$normal, + borderTopStyle: theme.borderStyles.$solid, + borderTopColor: theme.colors.$borderAlpha100, + })} + /> +); diff --git a/packages/ui/src/components/ConfigureSSO/elements/Step.tsx b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx new file mode 100644 index 00000000000..a3fc56adebd --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Step.tsx @@ -0,0 +1,206 @@ +import { type PropsWithChildren } from 'react'; + +import { + Badge, + Button, + Col, + descriptors, + Flex, + Heading, + Icon, + type LocalizationKey, + Text, + useLocalizations, +} from '@/customizables'; +import { CaretLeft, CaretRight } from '@/icons'; +import type { PropsOfComponent } from '@/styledSystem'; + +import { ProfileCardFooter } from './ProfileCard'; + +type StepLayoutProps = PropsOfComponent; + +const Layout = ({ sx, ...props }: StepLayoutProps): JSX.Element => ( + +); + +type StepSectionProps = PropsOfComponent; + +const Section = ({ sx, ...props }: StepSectionProps): JSX.Element => ( + ({ padding: theme.space.$5 }), sx]} + /> +); + +type StepHeaderProps = PropsWithChildren<{ + title: LocalizationKey | string; + description?: LocalizationKey | string; +}>; + +const Header = ({ title, description, children }: StepHeaderProps): JSX.Element => { + const { t } = useLocalizations(); + const titleText = typeof title === 'string' ? title : t(title); + const descriptionText = description ? (typeof description === 'string' ? description : t(description)) : null; + + return ( +
({ + borderBottomWidth: theme.borderWidths.$normal, + borderBottomStyle: theme.borderStyles.$solid, + borderBottomColor: theme.colors.$borderAlpha100, + })} + > + ({ gap: theme.space.$4 })} + > + ({ gap: theme.space.$1x5, minWidth: 0 })}> + ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} + > + {titleText} + + + {descriptionText && ( + ({ color: theme.colors.$colorMutedForeground })} + > + {descriptionText} + + )} + + + {children} + +
+ ); +}; + +type StepBodyProps = PropsOfComponent; + +const Body = ({ sx, ...props }: StepBodyProps): JSX.Element => ( + +); + +type FooterButtonProps = { + /** Click handler. Required — the buttons have no default behavior. */ + onClick?: () => void | Promise; + /** Disabled state. */ + isDisabled?: boolean; + /** Loading state. */ + isLoading?: boolean; + /** Override label. Defaults to 'Previous' / 'Continue'. */ + label?: LocalizationKey | string; +}; + +const FooterPrevious = ({ onClick, isDisabled, isLoading, label = 'Previous' }: FooterButtonProps): JSX.Element => { + const { t } = useLocalizations(); + const labelText = typeof label === 'string' ? label : t(label); + const handleClick = onClick + ? (): void => { + void onClick(); + } + : undefined; + + return ( + + ); +}; +FooterPrevious.displayName = 'Step.Footer.Previous'; + +const FooterContinue = ({ onClick, isDisabled, isLoading, label = 'Continue' }: FooterButtonProps): JSX.Element => { + const { t } = useLocalizations(); + const labelText = typeof label === 'string' ? label : t(label); + const handleClick = onClick + ? (): void => { + void onClick(); + } + : undefined; + + return ( + + ); +}; +FooterContinue.displayName = 'Step.Footer.Continue'; + +const Footer = ({ children }: PropsWithChildren): JSX.Element => {children}; + +const FooterCompound = Object.assign(Footer, { + Previous: FooterPrevious, + Continue: FooterContinue, +}); + +type StepCounterProps = { + total: number; + /** 1-indexed (i.e., first step = 1, not 0). */ + current: number; +}; + +const Counter = ({ total, current }: StepCounterProps): JSX.Element | null => { + if (total <= 1) { + return null; + } + + return ( + + ({ fontSize: t.fontSizes.$xs })} + > + Step {current}/{total} + + + ); +}; +Counter.displayName = 'Step.Counter'; + +export const Step = Object.assign(Layout, { + Section, + Header, + Body, + Footer: FooterCompound, + Counter, +}); diff --git a/packages/ui/src/components/ConfigureSSO/elements/Stepper/Stepper.tsx b/packages/ui/src/components/ConfigureSSO/elements/Stepper/Stepper.tsx new file mode 100644 index 00000000000..29014b278ef --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Stepper/Stepper.tsx @@ -0,0 +1,163 @@ +import React from 'react'; + +import { Box, Button, descriptors, Flex, Icon, Text, useLocalizations } from '@/customizables'; +import { CaretRight } from '@/icons'; + +import type { StepperItemProps, StepperProps } from './types'; + +const Root = ({ children }: StepperProps): JSX.Element => { + const items = React.Children.toArray(children).filter(child => React.isValidElement(child)); + + return ( + ({ + gap: theme.space.$2, + flexWrap: 'wrap', + })} + > + {items.map((child, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {child} + {index < items.length - 1 && ( + ({ color: theme.colors.$colorMutedForeground })} + /> + )} + + ))} + + ); +}; + +const Item = ({ + label, + bullet, + isCurrent, + isCompleted, + isReachable = true, + onClick, +}: StepperItemProps): JSX.Element => { + const { t } = useLocalizations(); + const labelText = label ? (typeof label === 'string' ? label : t(label)) : ''; + + return ( + + ); +}; +Item.displayName = 'Stepper.Item'; + +const ItemSkeleton = (): JSX.Element => ( + ({ gap: t.space.$1x5 })} + > + ({ + width: t.sizes.$4, + height: t.sizes.$4, + borderRadius: t.radii.$circle, + backgroundColor: t.colors.$neutralAlpha100, + })} + /> + ({ + width: t.sizes.$17, + height: t.sizes.$1x5, + borderRadius: t.radii.$md, + backgroundColor: t.colors.$neutralAlpha100, + })} + /> + +); + +type SkeletonProps = { + totalSteps?: number; +}; + +const Skeleton = ({ totalSteps = 4 }: SkeletonProps): JSX.Element => ( + ({ + gap: theme.space.$2, + flexWrap: 'wrap', + })} + > + {Array.from({ length: totalSteps }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + {index < totalSteps - 1 && ( + ({ color: theme.colors.$neutralAlpha100 })} + /> + )} + + ))} + +); + +/** + * Numbered step indicator — purely presentational. + * + * Each `` is a self-rendering component that takes + * `label`, `bullet`, `isCurrent`, `isCompleted`, `isReachable`, and + * `onClick`. The Stepper container only handles layout (gap + + * inserts a chevron separator between items). It does NOT walk + * children to extract descriptors, NOT compute reachability, NOT + * track current state — all of that is the host's responsibility. + * + * For the wizard surface, see `ConfigureSSOHeader` which maps + * `useWizard()` state into Stepper.Item props. + */ +export const Stepper = Object.assign(Root, { + Item, + Skeleton, +}); diff --git a/packages/ui/src/components/ConfigureSSO/elements/Stepper/index.ts b/packages/ui/src/components/ConfigureSSO/elements/Stepper/index.ts new file mode 100644 index 00000000000..62aa54fcd0e --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Stepper/index.ts @@ -0,0 +1,2 @@ +export { Stepper } from './Stepper'; +export type { StepperItemProps, StepperProps } from './types'; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Stepper/types.ts b/packages/ui/src/components/ConfigureSSO/elements/Stepper/types.ts new file mode 100644 index 00000000000..bebeabb1158 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Stepper/types.ts @@ -0,0 +1,22 @@ +import type React from 'react'; + +import type { LocalizationKey } from '@/customizables'; + +export interface StepperItemProps { + /** Display label. Resolved by the caller before passing in. */ + label?: LocalizationKey | string; + /** Content to render inside the bullet circle (typically a number or a check icon). Caller decides what to show. */ + bullet?: React.ReactNode; + /** Whether this is the active item. Bullet renders in foreground color. */ + isCurrent?: boolean; + /** Whether this item is past/done. Bullet renders in success color, label in foreground color. */ + isCompleted?: boolean; + /** Whether the user can click this item to jump to it. When false, the button is `isDisabled`. Defaults to true. */ + isReachable?: boolean; + /** Click handler. Called regardless of `isReachable` — caller can no-op when not reachable, but the button itself just dispatches. */ + onClick?: () => void; +} + +export interface StepperProps { + children: React.ReactNode; +} diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx new file mode 100644 index 00000000000..77300eeac98 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/Wizard.tsx @@ -0,0 +1,193 @@ +import React from 'react'; + +import type { WizardActiveStep, WizardContextValue, WizardStepProps } from './types'; +import { useWizard, WizardContext } from './WizardContext'; + +interface RootProps { + children: React.ReactNode; + /** + * Initial active step id. When provided, the wizard mounts with this + * step active. When omitted, the first registered step becomes the + * default — useful when steps are statically known and order is the + * source of truth, or when the host derives the initial step from + * external state (e.g., a server-state hook) and passes it down. + */ + initialStepId?: string; +} + +const Root = ({ children, initialStepId }: RootProps): JSX.Element => { + const parentWizard = React.useContext(WizardContext); + const isNested = parentWizard !== null; + + return ( + + {children} + + ); +}; + +interface RootInnerProps { + parentWizard: WizardContextValue | null; + isNested: boolean; + initialStepId?: string; + children: React.ReactNode; +} + +const RootInner = ({ parentWizard, isNested, initialStepId, children }: RootInnerProps): JSX.Element => { + // Stable registry of mounted Steps. Insertion order = JSX order = + // display order + const [activeSteps, setActiveSteps] = React.useState([]); + // Active step id. Defaults to the first registered step's id when + // `initialStepId` is omitted; otherwise mounts with the explicit id. + const [currentStepId, setCurrentStepId] = React.useState(initialStepId); + + const registerStep = React.useCallback((step: WizardActiveStep) => { + setActiveSteps(prev => { + const existingIndex = prev.findIndex(s => s.id === step.id); + if (existingIndex >= 0) { + const existing = prev[existingIndex]; + if (existing.label === step.label && existing.isCompleted === step.isCompleted) { + return prev; + } + // Update descriptor in place to preserve declaration order + const next = prev.slice(); + next[existingIndex] = step; + return next; + } + return [...prev, step]; + }); + // First registered step becomes the default active step + setCurrentStepId(prev => prev ?? step.id); + }, []); + + const unregisterStep = React.useCallback((id: string) => { + setActiveSteps(prev => (prev.some(s => s.id === id) ? prev.filter(s => s.id !== id) : prev)); + setCurrentStepId(prev => (prev === id ? undefined : prev)); + }, []); + + const currentStep = React.useMemo(() => activeSteps.find(s => s.id === currentStepId), [activeSteps, currentStepId]); + + const currentIndex = React.useMemo( + () => (currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1), + [activeSteps, currentStep], + ); + + const goNext = React.useCallback(() => { + if (currentIndex < 0) { + return; + } + const next = activeSteps[currentIndex + 1]; + if (next) { + setCurrentStepId(next.id); + return; + } + // Inner-last-step: bubble to parent wizard + return parentWizard?.goNext(); + }, [activeSteps, currentIndex, parentWizard]); + + const goPrev = React.useCallback(() => { + if (currentIndex < 0) { + return; + } + const prev = activeSteps[currentIndex - 1]; + if (prev) { + setCurrentStepId(prev.id); + return; + } + return parentWizard?.goPrev(); + }, [activeSteps, currentIndex, parentWizard]); + + const goToStep = React.useCallback( + (id: string) => { + if (activeSteps.some(s => s.id === id)) { + setCurrentStepId(id); + } + }, + [activeSteps], + ); + + const value = React.useMemo( + () => ({ + activeSteps, + currentStep, + currentIndex, + totalSteps: activeSteps.length, + isNested, + isFirstStep: currentIndex <= 0 && (!parentWizard || parentWizard.isFirstStep), + isLastStep: currentIndex === activeSteps.length - 1 && (!parentWizard || parentWizard.isLastStep), + goNext, + goPrev, + goToStep, + registerStep, + unregisterStep, + }), + [ + activeSteps, + currentStep, + currentIndex, + isNested, + parentWizard, + goNext, + goPrev, + goToStep, + registerStep, + unregisterStep, + ], + ); + + return {children}; +}; + +const Step = ({ id, label, isCompleted, children }: WizardStepProps): JSX.Element | null => { + const { registerStep, unregisterStep, currentStep } = useWizard(); + + // Update the descriptor on every prop change. Uses `useLayoutEffect` + // so registration commits before paint — keeps the first-frame + // flicker imperceptible + React.useLayoutEffect(() => { + registerStep({ id, label, isCompleted }); + }, [id, label, isCompleted, registerStep]); + + // Separate unmount-only cleanup so descriptor updates above don't + // transiently remove the step from the registry + React.useLayoutEffect(() => { + return () => unregisterStep(id); + }, [id, unregisterStep]); + + if (currentStep?.id !== id) { + return null; + } + return <>{children}; +}; +Step.displayName = 'Wizard.Step'; + +/** + * Declarative wizard primitive — UI-less, state-driven. + * + * Steps are written as JSX children: render a `` for + * each step and toggle visibility with regular conditional + * expressions (`{cond && ...}`). + * + * Steps register themselves with the parent wizard on mount via + * effect, so the wizard's `activeSteps` list always reflects exactly + * what's currently rendered. Inner sub-steps are declared by nesting + * another `` inside a step's body. + * + * The Wizard root renders `{children}` directly — no chrome, no + * routing, no layout wrapper. Header, Footer, and any step indicator + * are provided by the host layout via `useWizard()`. Each step owns + * its own footer via ``, which reads the nearest wizard + * via `useWizard()` so nested-wizard fall-through works automatically. + * + * When mounted without `initialStepId`, the wizard defaults to the + * first registered step. When `initialStepId` is provided, the wizard + * starts on that step instead — useful for resuming a flow from + * server-derived state. + */ +export const Wizard = Object.assign(Root, { + Step, +}); diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/WizardContext.tsx b/packages/ui/src/components/ConfigureSSO/elements/Wizard/WizardContext.tsx new file mode 100644 index 00000000000..fb46e59ad83 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/WizardContext.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import type { WizardContextValue } from './types'; + +export const WizardContext = React.createContext(null); +WizardContext.displayName = 'WizardContext'; + +export function useWizard(): WizardContextValue { + const ctx = React.useContext(WizardContext); + + if (!ctx) { + throw new Error('useWizard called outside of '); + } + + return ctx; +} diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/index.ts b/packages/ui/src/components/ConfigureSSO/elements/Wizard/index.ts new file mode 100644 index 00000000000..3169a07311d --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/index.ts @@ -0,0 +1,3 @@ +export { Wizard } from './Wizard'; +export { useWizard } from './WizardContext'; +export type { WizardActiveStep, WizardContextValue, WizardStepProps } from './types'; diff --git a/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts b/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts new file mode 100644 index 00000000000..d268944f566 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/elements/Wizard/types.ts @@ -0,0 +1,107 @@ +import type React from 'react'; + +import type { LocalizationKey } from '@/customizables'; + +/** + * Props for ``. Each rendered Step is one navigable + * position in its parent ``. Inner sub-steps are declared by + * nesting another `` inside the Step's body + */ +export interface WizardStepProps { + /** + * Stable identifier for the step. Used as a React key, for + * `goToStep(id)`, and to register the step with the parent wizard + */ + id: string; + /** + * Label shown in the breadcrumb at the top of the wizard. Only + * outermost steps need a label — inner steps reuse their parent's + * breadcrumb entry + */ + label?: LocalizationKey | string; + /** + * Marks this step as completed regardless of its position relative + * to the current step + */ + isCompleted?: boolean; + /** + * The step body. Anything React, including a nested + * `` for inner sub-steps + */ + children: React.ReactNode; +} + +/** + * Internal step descriptor mirrored from a Step's props once it has + * registered itself with the parent wizard. Consumers shouldn't need + * to construct these directly + */ +export interface WizardActiveStep { + id: string; + label?: LocalizationKey | string; + isCompleted?: boolean; +} + +export interface WizardContextValue { + /** + * The active siblings inside the *current* Wizard scope, in JSX + * declaration order. Steps register themselves on mount and + * unregister on unmount + */ + activeSteps: WizardActiveStep[]; + /** + * The step currently rendered as the wizard's body, or `undefined` + * before the first step has registered + */ + currentStep: WizardActiveStep | undefined; + /** + * Index of `currentStep` within `activeSteps`. `-1` if not matched + */ + currentIndex: number; + /** + * Convenience: `activeSteps.length` + */ + totalSteps: number; + /** + * `true` when the user is at the very first position inside *this* + * wizard scope and there is no parent wizard to fall back on + */ + isFirstStep: boolean; + /** + * `true` when the user is at the very last position inside *this* + * wizard scope and there is no parent wizard to fall back on + */ + isLastStep: boolean; + /** + * `true` when this wizard is rendered inside another wizard. The + * outermost wizard owns the breadcrumb / footer chrome; nested + * wizards just contribute their own active step bodies + */ + isNested: boolean; + /** + * Navigate forward. Within this wizard, advances to the next active + * sibling. On the last sibling, falls through to the parent + * wizard's `goNext` (if any) + */ + goNext: () => Promise | void; + /** + * Navigate backward. Mirror of `goNext`: previous sibling, then + * back to the parent's last sibling on overflow + */ + goPrev: () => Promise | void; + /** + * Jump to a specific step by `id` within this wizard scope. No-op + * if the id is not in `activeSteps` + */ + goToStep: (id: string) => Promise | void; + /** + * Internal — called by `` on mount (and again whenever + * its descriptor props change) to register itself with the wizard + */ + registerStep: (step: WizardActiveStep) => void; + /** + * Internal — called by `` on unmount (or when its `id` + * changes) to remove itself from the wizard's active steps + */ + unregisterStep: (id: string) => void; +} diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx deleted file mode 100644 index 15193247e84..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureCreateAppStep.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Flow, Text } from '@/customizables'; - -import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; -import { StepLayout } from './StepLayout'; - -export const ConfigureCreateApp = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); - - useRegisterContinueAction({ - handler: () => goNext(), - }); - - return ( - - - UI goes here - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx new file mode 100644 index 00000000000..5b19dbb43c9 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep.tsx @@ -0,0 +1,39 @@ +import { descriptors, Flow, Text } from '@/customizables'; + +import { Step } from '../elements/Step'; +import { useWizard } from '../elements/Wizard'; + +export const ConfigureStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + + + + + + + UI goes here + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx index 0f6cbf2c49e..e2947e15342 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfirmationStep.tsx @@ -1,13 +1,34 @@ -import { Flow, Text } from '@/customizables'; +import { descriptors, Flow, Text } from '@/customizables'; -import { StepLayout } from './StepLayout'; +import { Step } from '../elements/Step'; +import { useWizard } from '../elements/Wizard'; export const ConfirmationStep = (): JSX.Element => { + const { goPrev, isFirstStep } = useWizard(); + return ( - - UI goes here - + + + + UI goes here + + + + + goPrev()} + isDisabled={isFirstStep} + /> + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx deleted file mode 100644 index bf5afe4762e..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/ProvideEmailStep.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Flow, Text } from '@/customizables'; - -import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; -import { StepLayout } from './StepLayout'; - -export const ProvideEmail = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); - - useRegisterContinueAction({ - handler: () => { - return goNext(); - }, - }); - - return ( - - - UI goes here - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx new file mode 100644 index 00000000000..f27de65c496 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/SelectProviderStep.tsx @@ -0,0 +1,39 @@ +import { descriptors, Flow, Text } from '@/customizables'; + +import { Step } from '../elements/Step'; +import { useWizard } from '../elements/Wizard'; + +export const SelectProviderStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + + + + + + + UI goes here + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx b/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx deleted file mode 100644 index 8845d841142..00000000000 --- a/packages/ui/src/components/ConfigureSSO/steps/StepLayout.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; - -import { Col, Flex, Heading, Text } from '@/customizables'; - -import { ConfigureSSOWizard } from '../wizard'; - -interface StepLayoutProps { - title?: React.ReactNode; - subtitle?: React.ReactNode; - children: React.ReactNode; -} - -/** - * Renders the title row (with the Wizard's Step X/Y badge) on top, a divider, and the step body - * underneath. Each individual step file owns the body content - * - * The Step X/Y badge is rendered via `ConfigureSSOWizard.StepIndicator`, - * which self-hides on steps that have no inner sub-steps - */ -export const StepLayout = ({ title, subtitle, children }: StepLayoutProps): JSX.Element => { - return ( - - ({ - gap: theme.space.$4, - padding: theme.space.$5, - })} - > - {title ? ( - ({ gap: theme.space.$1, minWidth: 0 })}> - ({ color: theme.colors.$colorForeground, fontSize: theme.fontSizes.$lg })} - > - {title} - - - {subtitle ? ( - ({ color: theme.colors.$colorMutedForeground })} - > - {subtitle} - - ) : null} - - ) : null} - - - ({ - flex: 1, - paddingInline: theme.space.$5, - overflowY: 'auto', - })} - > - {children} - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx index 31c1ab907de..91b7d7a214c 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/TestConfigurationStep.tsx @@ -1,16 +1,49 @@ -import { Flow, Text } from '@/customizables'; +import { descriptors, Flow, Text } from '@/customizables'; -import { StepLayout } from './StepLayout'; +import { Step } from '../elements/Step'; +import { useWizard } from '../elements/Wizard'; export const TestConfigurationStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + return ( - - UI goes here - + + + + ({ + borderBottomWidth: theme.borderWidths.$normal, + borderBottomStyle: theme.borderStyles.$solid, + borderBottomColor: theme.colors.$borderAlpha100, + })} + > + Test your SSO URL + + + + Your test results + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + ); }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx index 9c37d3261d9..8fdf519de5b 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/VerifyDomainStep.tsx @@ -1,25 +1,104 @@ -import { Flow, Text } from '@/customizables'; +import { useUser } from '@clerk/shared/react'; -import { useConfigureSSOWizard, useRegisterContinueAction } from '../wizard'; -import { StepLayout } from './StepLayout'; +import { descriptors, Flow, Text } from '@/customizables'; -export const VerifyDomainStep = (): JSX.Element => { - const { goNext } = useConfigureSSOWizard(); +import { Step } from '../elements/Step'; +import { useWizard, Wizard } from '../elements/Wizard'; - useRegisterContinueAction({ - handler: () => goNext(), - // TODO: Implement verification - isDisabled: true, - }); +export const VerifyDomainStep = (): JSX.Element => { + const { user } = useUser(); + const primaryEmailAddress = user?.primaryEmailAddress; return ( - - UI goes here - + + + + + + + {!primaryEmailAddress && ( + + + + )} + + + + + + ); }; + +const InnerStepCounter = (): JSX.Element => { + const { currentIndex, totalSteps } = useWizard(); + return ( + + ); +}; + +export const ProvideEmailStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + UI goes here + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; + +export const EnterVerificationCodeStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + UI goes here + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/index.ts b/packages/ui/src/components/ConfigureSSO/steps/index.ts index 300535512e9..f505cad9979 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/index.ts +++ b/packages/ui/src/components/ConfigureSSO/steps/index.ts @@ -1,6 +1,5 @@ -export { ConfigureCreateApp } from './ConfigureCreateAppStep'; +export { ConfigureStep } from './ConfigureStep'; export { ConfirmationStep } from './ConfirmationStep'; -export { ProvideEmail } from './ProvideEmailStep'; -export { StepLayout } from './StepLayout'; +export { SelectProviderStep } from './SelectProviderStep'; export { TestConfigurationStep } from './TestConfigurationStep'; export { VerifyDomainStep } from './VerifyDomainStep'; diff --git a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx b/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx deleted file mode 100644 index 0cbe9614367..00000000000 --- a/packages/ui/src/components/ConfigureSSO/wizard/ConfigureSSOWizard.tsx +++ /dev/null @@ -1,554 +0,0 @@ -import React from 'react'; - -import { Badge, Box, Button, Col, descriptors, Flex, Icon, Spinner, Text, useLocalizations } from '@/customizables'; -import { CaretLeft, CaretRight, Check } from '@/icons'; -import { Route, Switch, useRouter } from '@/router'; - -import { useConfigureSSOFlow } from '../ConfigureSSOContext'; -import { - ConfigureSSOWizardContext, - useConfigureSSOWizard, - useRegisterWizard, - useWizardChromeRegistry, - WizardChromeProvider, -} from './ConfigureSSOWizardContext'; -import type { - ConfigureSSOWizardActiveStep, - ConfigureSSOWizardContextValue, - ConfigureSSOWizardStepProps, -} from './types'; - -const Step = (_: ConfigureSSOWizardStepProps): JSX.Element | null => null; -Step.displayName = 'ConfigureSSOWizard.Step'; - -interface RootProps { - children: React.ReactNode; -} - -/** - * Walks the wizard's children and returns the descriptors for every - * `` element - */ -function extractSteps(children: React.ReactNode): ConfigureSSOWizardActiveStep[] { - const steps: ConfigureSSOWizardActiveStep[] = []; - - React.Children.forEach(children, child => { - if (!React.isValidElement(child)) { - return; - } - - // Tolerate fragments at the top level (e.g. when users factor a - // group of steps into a helper component that returns one) - if (child.type === React.Fragment) { - const fragmentChildren = (child.props as { children?: React.ReactNode }).children; - steps.push(...extractSteps(fragmentChildren)); - return; - } - - if (child.type !== Step) { - return; - } - - const props = child.props as ConfigureSSOWizardStepProps; - steps.push({ - id: props.id, - path: props.path, - label: props.label, - isCompleted: props.isCompleted, - children: props.children, - }); - }); - - return steps; -} - -const Root = ({ children }: RootProps): JSX.Element => { - const parentWizard = React.useContext(ConfigureSSOWizardContext); - const isNested = parentWizard !== null; - - // Outermost wizard owns the shared chrome registry. Nested wizards - // reuse whatever the outer one provided, so registrations bubble up - if (!isNested) { - return ( - - - {children} - - - ); - } - - return ( - - {children} - - ); -}; - -interface RootInnerProps { - parentWizard: ConfigureSSOWizardContextValue | null; - isNested: boolean; - children: React.ReactNode; -} - -const RootInner = ({ parentWizard, isNested, children }: RootInnerProps): JSX.Element => { - const router = useRouter(); - const flow = useConfigureSSOFlow(); - const { isLoading } = flow; - - const activeSteps = React.useMemo(() => extractSteps(children), [children]); - - // Match the URL against non-first steps (most-specific first), the - // first step is mounted as the index route and is always the - // fallback when nothing else matches - const currentStep = React.useMemo(() => { - if (activeSteps.length === 0) { - return undefined; - } - - return ( - activeSteps - .slice(1) - .reverse() - .find(s => router.matches(s.path)) ?? activeSteps[0] - ); - }, [activeSteps, router]); - - const buildPath = React.useCallback( - (step: ConfigureSSOWizardActiveStep): string => { - const isFirst = activeSteps[0]?.id === step.id; - return isFirst ? './' : step.path; - }, - [activeSteps], - ); - - const navigateTo = React.useCallback( - (step: ConfigureSSOWizardActiveStep | undefined) => (step ? router.navigate(buildPath(step)) : undefined), - [router, buildPath], - ); - - const goNext = React.useCallback(() => { - if (!currentStep) { - return; - } - - const index = activeSteps.findIndex(s => s.id === currentStep.id); - const next = activeSteps[index + 1]; - if (next) { - return navigateTo(next); - } - - return parentWizard?.goNext(); - }, [activeSteps, currentStep, navigateTo, parentWizard]); - - const goPrev = React.useCallback(() => { - if (!currentStep) { - return; - } - - const index = activeSteps.findIndex(s => s.id === currentStep.id); - const prev = activeSteps[index - 1]; - if (prev) { - return navigateTo(prev); - } - - return parentWizard?.goPrev(); - }, [activeSteps, currentStep, navigateTo, parentWizard]); - - const goToStep = React.useCallback( - (id: string) => navigateTo(activeSteps.find(s => s.id === id)), - [activeSteps, navigateTo], - ); - - const value = React.useMemo(() => { - const index = currentStep ? activeSteps.findIndex(s => s.id === currentStep.id) : -1; - return { - activeSteps, - currentStep, - currentIndex: index, - totalSteps: activeSteps.length, - isLoading, - goNext, - goPrev, - goToStep, - isNested, - isFirstStep: index <= 0 && (!parentWizard || parentWizard.isFirstStep), - isLastStep: index === activeSteps.length - 1 && (!parentWizard || parentWizard.isLastStep), - }; - }, [activeSteps, currentStep, isLoading, goNext, goPrev, goToStep, isNested, parentWizard]); - - // Push this wizard onto the chrome stack so the shared footer can - // dispatch Continue / Previous to the *deepest* mounted wizard, - // not just the outermost one - useRegisterWizard(value); - - const body = ; - - if (isNested) { - return {body}; - } - - // Outermost wizard owns the full layout - return ( - -
- {body} -