diff --git a/.changeset/configure-sso-modal-close-button-height.md b/.changeset/configure-sso-modal-close-button-height.md new file mode 100644 index 00000000000..b829798a51d --- /dev/null +++ b/.changeset/configure-sso-modal-close-button-height.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix the close button in the organization profile's SSO configuration wizard growing the modal header taller than its other content. diff --git a/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx b/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx index 90a924d3fbe..916c831b52d 100644 --- a/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx +++ b/packages/ui/src/components/ConfigureSSO/ConfigureSSOHeader.tsx @@ -1,4 +1,10 @@ -import { Flex, useLocalizations } from '@/customizables'; +import { useSafeLayoutEffect } from '@clerk/shared/react'; + +import { descriptors, Flex, Icon, useLocalizations } from '@/customizables'; +import { IconButton } from '@/elements/IconButton'; +import { useUnsafeModalContext } from '@/elements/Modal'; +import { useUnsafeProfileCardCloseButton } from '@/elements/ProfileCard'; +import { Close } from '@/icons'; import { mqu } from '@/styledSystem'; import { ProfileCardHeader } from './elements/ProfileCard'; @@ -12,6 +18,19 @@ type ConfigureSSOHeaderProps = { export const ConfigureSSOHeader = ({ title }: ConfigureSSOHeaderProps): JSX.Element => { const { activeSteps, currentIndex, goToStep } = useWizard(); const { t } = useLocalizations(); + const { toggle } = useUnsafeModalContext(); + const { setHeaderOwnsCloseButton } = useUnsafeProfileCardCloseButton(); + const isModal = Boolean(toggle); + + // In modal mode the header renders its own close button, so the card's shared + // absolute overlay hides on desktop. Lifecycle registration only. + useSafeLayoutEffect(() => { + if (!isModal) { + return; + } + setHeaderOwnsCloseButton?.(true); + return () => setHeaderOwnsCloseButton?.(false); + }, [isModal, setHeaderOwnsCloseButton]); // Breadcrumb membership = labelled steps. `select-provider` has no label, so // it is absent from the visual stepper while remaining a real navigable step. @@ -45,6 +64,28 @@ export const ConfigureSSOHeader = ({ title }: ConfigureSSOHeaderProps): JSX.Elem })} + + {isModal && ( + + } + sx={t => ({ + color: t.colors.$colorMutedForeground, + // 4px symmetric padding → 24px box that matches the stepper bullets and gives the ghost hover a clean square (not a squished pill). + padding: t.space.$1, + // Desktop-only: mobile falls back to the card's absolute close button. + [mqu.md]: { display: 'none' }, + })} + /> + )} ); }; diff --git a/packages/ui/src/components/ConfigureSSO/elements/ProfileCard.tsx b/packages/ui/src/components/ConfigureSSO/elements/ProfileCard.tsx index 031528cdcf0..e0647cf8e19 100644 --- a/packages/ui/src/components/ConfigureSSO/elements/ProfileCard.tsx +++ b/packages/ui/src/components/ConfigureSSO/elements/ProfileCard.tsx @@ -9,10 +9,12 @@ export const ProfileCardHeader = (props: ProfileCardHeaderProps): JSX.Element => elementDescriptor={descriptors.configureSSOHeader} {...props} sx={theme => ({ - gap: theme.space.$2, + gap: theme.space.$3, width: '100%', minHeight: theme.sizes.$13, padding: theme.space.$5, + // Vertically center the back button, stepper, and (modal) close button. + alignItems: 'center', borderBottomWidth: theme.borderWidths.$normal, borderBottomStyle: theme.borderStyles.$solid, borderBottomColor: theme.colors.$borderAlpha100, diff --git a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx index 037bb86b5ed..528d68ee564 100644 --- a/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx +++ b/packages/ui/src/components/OrganizationProfile/__tests__/OrganizationSecurityPage.test.tsx @@ -1,6 +1,7 @@ import { ClerkAPIResponseError } from '@clerk/shared/error'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { ModalContext } from '@/elements/Modal'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, screen, waitFor } from '@/test/utils'; @@ -48,6 +49,19 @@ const configuredConnection = (overrides: Record = {}) => const renderPage = (wrapper: React.ComponentType<{ children?: React.ReactNode }>) => render(, { wrapper }); +// Renders the page inside a modal context (a `toggle` is provided), mirroring how +// the modal-mode OrganizationProfile owns the close button. +const renderPageInModal = (wrapper: React.ComponentType<{ children?: React.ReactNode }>, toggle: () => void) => { + const FixtureWrapper = wrapper; + const modalValue = { value: { toggle } }; + const ModalWrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + return render(, { wrapper: ModalWrapper }); +}; + describe('OrganizationSecurityPage', () => { describe('overview states', () => { it('renders the unconfigured state with a Start configuration action', async () => { @@ -295,6 +309,52 @@ describe('OrganizationSecurityPage', () => { }); }); + describe('wizard modal close button', () => { + it('renders a header close button wired to the modal toggle when the wizard is open in a modal', async () => { + const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); + + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); + fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: [verifiedDomain], total_count: 1 } as any); + + const toggle = vi.fn(); + const { userEvent } = renderPageInModal(wrapper, toggle); + + await userEvent.click(await screen.findByRole('button', { name: 'Start configuration' })); + expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument(); + + // jsdom can't evaluate the desktop/mobile media split, so assert behavior: + // the header exposes a close button that drives the modal's toggle. + const closeButton = screen.getByRole('button', { name: 'Close modal' }); + await userEvent.click(closeButton); + expect(toggle).toHaveBeenCalled(); + }); + + it('does not render the wizard close button when the wizard is open outside a modal', async () => { + const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); + + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); + fixtures.clerk.organization?.getDomains.mockResolvedValue({ data: [verifiedDomain], total_count: 1 } as any); + + const { userEvent } = renderPage(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Start configuration' })); + expect(await screen.findByRole('heading', { name: /add SSO domains/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close modal' })).not.toBeInTheDocument(); + }); + + it('keeps the overview free of the wizard close button inside a modal', async () => { + const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); + + fixtures.clerk.organization?.getEnterpriseConnections.mockResolvedValue([]); + + const toggle = vi.fn(); + renderPageInModal(wrapper, toggle); + + expect(await screen.findByRole('button', { name: 'Start configuration' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Close modal' })).not.toBeInTheDocument(); + }); + }); + describe('actions menu', () => { it('lists Edit, Deactivate, and Remove for an active connection', async () => { const { wrapper, fixtures } = await createFixtures(withSecurityPageFixtures); diff --git a/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx b/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx index 6944282d3e5..d4879bac81c 100644 --- a/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx +++ b/packages/ui/src/elements/ProfileCard/ProfileCardRoot.tsx @@ -1,3 +1,4 @@ +import { createContextAndHook } from '@clerk/shared/react'; import React from 'react'; import { Box, descriptors, Icon } from '../../customizables'; @@ -8,9 +9,18 @@ import { Card } from '../Card'; import { IconButton } from '../IconButton'; import { useUnsafeModalContext } from '../Modal'; +// Lets a descendant header (the SSO wizard) claim the modal close button so the +// shared absolute overlay can step aside on desktop. No card sets this by default, +// so every existing consumer keeps the absolute button unchanged. +export const [ProfileCardCloseButtonContext, _useProfileCardCloseButton, useUnsafeProfileCardCloseButton] = + createContextAndHook<{ setHeaderOwnsCloseButton: (owns: boolean) => void }>('ProfileCardCloseButtonContext'); + export const ProfileCardRoot = React.forwardRef>((props, ref) => { const { sx, children, ...rest } = props; const { toggle } = useUnsafeModalContext(); + const [headerOwnsCloseButton, setHeaderOwnsCloseButton] = React.useState(false); + // The setState identity is stable, so empty deps are correct. + const closeButtonCtx = React.useMemo(() => ({ value: { setHeaderOwnsCloseButton } }), []); return (