diff --git a/.changeset/slow-berries-walk.md b/.changeset/slow-berries-walk.md new file mode 100644 index 00000000000..1822560c279 --- /dev/null +++ b/.changeset/slow-berries-walk.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Extract `` component to ensure consistency is usage across the UI components. diff --git a/packages/ui/src/common/ProviderIcon.tsx b/packages/ui/src/common/ProviderIcon.tsx new file mode 100644 index 00000000000..95d3359f4e8 --- /dev/null +++ b/packages/ui/src/common/ProviderIcon.tsx @@ -0,0 +1,106 @@ +import type { OAuthProvider, PhoneCodeChannel, Web3Provider } from '@clerk/shared/types'; + +import { descriptors, Span } from '../customizables'; +import type { ElementDescriptor, ElementId } from '../customizables/elementDescriptors'; +import type { InternalTheme, PropsOfComponent } from '../styledSystem'; +import { ProviderInitialIcon } from './ProviderInitialIcon'; + +type ProviderId = OAuthProvider | Web3Provider | PhoneCodeChannel; + +const SUPPORTS_MASK_IMAGE = ['apple', 'github', 'okx_wallet', 'vercel'] as const; + +const supportsMaskImage = (id: ProviderId): boolean => { + return (SUPPORTS_MASK_IMAGE as readonly string[]).includes(id); +}; + +const getIconImageStyles = (theme: InternalTheme, id: ProviderId, iconUrl: string) => { + if (supportsMaskImage(id)) { + return { + '--cl-icon-fill': theme.colors.$colorForeground, + backgroundColor: 'var(--cl-icon-fill)', + maskImage: `url(${iconUrl})`, + maskSize: 'cover', + maskPosition: 'center', + maskRepeat: 'no-repeat', + }; + } + + return { + backgroundImage: `url(${iconUrl})`, + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + }; +}; + +const getThemeSize = (theme: InternalTheme, size: string): string => { + return theme.sizes[size as keyof typeof theme.sizes] || size; +}; + +export type ProviderIconProps = Omit< + PropsOfComponent, + 'elementDescriptor' | 'elementId' | 'aria-label' +> & { + id: ProviderId; + iconUrl?: string | null; + name: string; + size?: string; + isLoading?: boolean; + isDisabled?: boolean; + alt?: string; + elementDescriptor?: ElementDescriptor | Array; + elementId?: ElementId; +}; + +export const ProviderIcon = (props: ProviderIconProps) => { + const { + id, + iconUrl, + name, + size = '$4', + isLoading, + isDisabled, + alt, + elementDescriptor = descriptors.providerIcon, + elementId, + sx, + ...rest + } = props; + + if (!iconUrl || iconUrl.trim() === '') { + const { ref, ...initialIconProps } = rest; + return ( + + ); + } + + return ( + { + const iconSize = getThemeSize(theme, size); + return [ + { + display: 'inline-block', + width: iconSize, + height: iconSize, + maxWidth: '100%', + opacity: isLoading || isDisabled ? 0.5 : 1, + ...getIconImageStyles(theme, id, iconUrl), + }, + sx, + ]; + }} + {...rest} + /> + ); +}; diff --git a/packages/ui/src/common/__tests__/ProviderIcon.test.tsx b/packages/ui/src/common/__tests__/ProviderIcon.test.tsx new file mode 100644 index 00000000000..48657e10a83 --- /dev/null +++ b/packages/ui/src/common/__tests__/ProviderIcon.test.tsx @@ -0,0 +1,432 @@ +import { describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen } from '@/test/utils'; + +import { ProviderIcon } from '../ProviderIcon'; + +const { createFixtures } = bindCreateFixtures('SignIn'); + +describe('ProviderIcon', () => { + describe('Rendering with iconUrl', () => { + it('renders Span with correct aria-label when iconUrl is provided', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + expect(icon.tagName).toBe('SPAN'); + }); + + it('uses custom alt text when provided', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Custom Google logo'); + expect(icon).toBeInTheDocument(); + }); + + it('applies mask-image styles for supported providers (apple)', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Apple icon'); + const styles = window.getComputedStyle(icon); + + // Check that mask-image is applied (via inline styles) + expect(icon).toHaveStyle({ + display: 'inline-block', + }); + }); + + it('applies mask-image styles for supported providers (github)', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('GitHub icon'); + expect(icon).toBeInTheDocument(); + }); + + it('applies mask-image styles for supported providers (okx_wallet)', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('OKX Wallet icon'); + expect(icon).toBeInTheDocument(); + }); + + it('applies mask-image styles for supported providers (vercel)', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Vercel icon'); + expect(icon).toBeInTheDocument(); + }); + + it('applies background-image styles for non-mask-image providers', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + }); + }); + + describe('Rendering without iconUrl', () => { + it('falls back to ProviderInitialIcon when iconUrl is null', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + // ProviderInitialIcon renders the first letter of the name + const initial = screen.getByText('G'); + expect(initial).toBeInTheDocument(); + }); + + it('falls back to ProviderInitialIcon when iconUrl is undefined', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const initial = screen.getByText('G'); + expect(initial).toBeInTheDocument(); + }); + + it('falls back to ProviderInitialIcon when iconUrl is empty string', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const initial = screen.getByText('A'); + expect(initial).toBeInTheDocument(); + }); + + it('falls back to ProviderInitialIcon when iconUrl is whitespace-only', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const initial = screen.getByText('G'); + expect(initial).toBeInTheDocument(); + }); + + it('passes isLoading prop to ProviderInitialIcon', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const initial = screen.getByText('G'); + expect(initial).toBeInTheDocument(); + }); + + it('passes isDisabled prop to ProviderInitialIcon', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const initial = screen.getByText('G'); + expect(initial).toBeInTheDocument(); + }); + }); + + describe('Loading and disabled states', () => { + it('applies opacity 0.5 when isLoading is true', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + const styles = window.getComputedStyle(icon); + expect(styles.opacity).toBe('0.5'); + }); + + it('applies opacity 0.5 when isDisabled is true', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + const styles = window.getComputedStyle(icon); + expect(styles.opacity).toBe('0.5'); + }); + + it('applies opacity 1 when neither isLoading nor isDisabled is true', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + const styles = window.getComputedStyle(icon); + expect(styles.opacity).toBe('1'); + }); + }); + + describe('Size prop', () => { + it('uses default size $4 when not provided', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + // Size is applied via theme, so we verify the element exists + // The actual size value depends on theme configuration + }); + + it('uses custom size when provided', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + }); + }); + + describe('Accessibility', () => { + it('sets aria-label from alt prop when provided', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google provider icon'); + expect(icon).toHaveAttribute('aria-label', 'Google provider icon'); + }); + + it('generates aria-label from name when alt is not provided', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toHaveAttribute('aria-label', 'Google icon'); + }); + + it('uses correct elementDescriptor', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + // Element descriptor is applied via data attributes in the styled system + }); + }); + + describe('Edge cases', () => { + it('handles providers with different casing', async () => { + const { wrapper } = await createFixtures(); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + }); + + it('handles custom elementDescriptor', async () => { + const { wrapper } = await createFixtures(); + const { descriptors } = await import('../../customizables'); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + }); + + it('handles custom elementId', async () => { + const { wrapper } = await createFixtures(); + const { descriptors } = await import('../../customizables'); + + render( + , + { wrapper }, + ); + + const icon = screen.getByLabelText('Google icon'); + expect(icon).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/common/index.ts b/packages/ui/src/common/index.ts index e1a892faa43..f859201d4b6 100644 --- a/packages/ui/src/common/index.ts +++ b/packages/ui/src/common/index.ts @@ -8,6 +8,7 @@ export * from './InfiniteListSpinner'; export * from './NotificationCountBadge'; export * from './PrintableComponent'; export * from './ProviderInitialIcon'; +export * from './ProviderIcon'; export * from './QRCode'; export * from './redirects'; export * from './RemoveResourceForm'; diff --git a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx index f8c3ec42d34..5c784ba612b 100644 --- a/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx +++ b/packages/ui/src/components/SignIn/SignInAlternativePhoneCodePhoneNumberCard.tsx @@ -6,7 +6,8 @@ import { Form } from '@/ui/elements/Form'; import { Header } from '@/ui/elements/Header'; import type { FormControlState } from '@/ui/utils/useFormControl'; -import { Button, Col, descriptors, Flex, Image, localizationKeys } from '../../customizables'; +import { ProviderIcon } from '../../common'; +import { Button, Col, descriptors, Flex, localizationKeys } from '../../customizables'; import { CaptchaElement } from '../../elements/CaptchaElement'; import { useEnabledThirdPartyProviders } from '../../hooks'; @@ -23,6 +24,7 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati const provider = phoneCodeProvider.name; const channel = phoneCodeProvider.channel; const card = useCardState(); + const strategyData = strategyToDisplayData[channel]; return ( @@ -32,16 +34,18 @@ export const SignInAlternativePhoneCodePhoneNumberCard = (props: SignUpAlternati showDivider > - {`${strategyToDisplayData[channel].name} ({ - width: theme.sizes.$7, - height: theme.sizes.$7, - maxWidth: '100%', - marginBottom: theme.sizes.$6, - })} - /> + {providerToDisplayData[channel] && ( + ({ + marginBottom: theme.sizes.$6, + })} + /> + )} { return !!fields[name] && fields[name]?.required; @@ -39,16 +41,18 @@ export const SignUpStartAlternativePhoneCodePhoneNumberCard = (props: SignUpForm showDivider > - {`${strategyToDisplayData[channel].name} ({ - width: theme.sizes.$7, - height: theme.sizes.$7, - maxWidth: '100%', - marginBottom: theme.sizes.$6, - })} - /> + {providerToDisplayData[phoneCodeProvider.channel] && ( + ({ + marginBottom: theme.sizes.$6, + })} + /> + )} voi }); }); - const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( - {`Connect ({ width: theme.sizes.$4 })} - /> - ) : ( - - ); - const connect = () => { if (!user) { return; @@ -93,7 +74,18 @@ const ConnectMenuButton = (props: { strategy: OAuthStrategy; onClick?: () => voi justifyContent: 'start', gap: t.space.$2, })} - leftIcon={imageOrInitial} + leftIcon={ + + } /> ); }; diff --git a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx index eaa0e746ba4..ff00fdb0f25 100644 --- a/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx +++ b/packages/ui/src/components/UserProfile/ConnectedAccountsSection.tsx @@ -9,9 +9,9 @@ import { ProfileSection } from '@/ui/elements/Section'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { handleError } from '@/ui/utils/errorHandler'; -import { ProviderInitialIcon } from '../../common'; +import { ProviderIcon } from '../../common'; import { useUserProfileContext } from '../../contexts'; -import { Box, Button, descriptors, Flex, Image, localizationKeys, Text } from '../../customizables'; +import { Box, Button, descriptors, Flex, localizationKeys, Text } from '../../customizables'; import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; import { useEnabledThirdPartyProviders } from '../../hooks'; @@ -117,11 +117,11 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => }), ); + const { providerToDisplayData } = useEnabledThirdPartyProviders(); + if (!user) { return null; } - - const { providerToDisplayData } = useEnabledThirdPartyProviders(); const label = account.username || account.emailAddress; const fallbackErrorMessage = account.verification?.error?.longMessage; const additionalScopes = findAdditionalScopes(account, additionalOAuthScopes); @@ -153,34 +153,28 @@ const ConnectedAccount = ({ account }: { account: ExternalAccountResource }) => } }; - const ImageOrInitial = () => - providerToDisplayData[account.provider].iconUrl ? ( - {providerToDisplayData[account.provider].name} ({ width: theme.sizes.$4, flexShrink: 0 })} - /> - ) : ( - - ); + const providerData = providerToDisplayData[account.provider]; return ( ({ overflow: 'hidden', gap: t.space.$2 })}> - + ({ color: t.colors.$colorForeground })}>{`${ - providerToDisplayData[account.provider].name + providerData?.name || account.provider }`} { const { user } = useUser(); @@ -86,25 +86,14 @@ const EnterpriseAccountProviderIcon = ({ account }: { account: EnterpriseAccount const providerWithoutPrefix = provider.replace(/(oauth_|saml_)/, '').trim() as OAuthProvider; const connectionName = enterpriseConnection?.name ?? providerWithoutPrefix; - const commonImageProps = { - elementDescriptor: [descriptors.providerIcon], - alt: connectionName, - sx: (theme: any) => ({ width: theme.sizes.$4 }), - elementId: descriptors.enterpriseButtonsProviderIcon.setId(account.provider), - }; - - return enterpriseConnection?.logoPublicUrl ? ( - - ) : ( - ); }; diff --git a/packages/ui/src/components/UserProfile/Web3Form.tsx b/packages/ui/src/components/UserProfile/Web3Form.tsx index a2765b22f44..19c4b320c7a 100644 --- a/packages/ui/src/components/UserProfile/Web3Form.tsx +++ b/packages/ui/src/components/UserProfile/Web3Form.tsx @@ -3,8 +3,9 @@ import { useReverification, useUser } from '@clerk/shared/react'; import type { Web3Provider, Web3Strategy } from '@clerk/shared/types'; import { useModuleManager } from '@/contexts'; -import { descriptors, Image, localizationKeys } from '@/customizables'; +import { descriptors, localizationKeys } from '@/customizables'; import { useEnabledThirdPartyProviders } from '@/hooks'; +import { ProviderIcon } from '@/ui/common'; import { Web3SelectSolanaWalletScreen } from '@/ui/components/UserProfile/Web3SelectSolanaWalletScreen'; import { Action } from '@/ui/elements/Action'; import { useActionContext } from '@/ui/elements/Action/ActionRoot'; @@ -91,14 +92,15 @@ export const AddWeb3WalletActionMenu = () => { gap: t.space.$2, })} leftIcon={ - {`Connect ({ width: theme.sizes.$5 })} + elementDescriptor={descriptors.providerIcon} + elementId={descriptors.providerIcon.setId(strategyToDisplayData[strategy].id)} /> } /> diff --git a/packages/ui/src/components/UserProfile/Web3Section.tsx b/packages/ui/src/components/UserProfile/Web3Section.tsx index 525246c6bd0..9dd7938f939 100644 --- a/packages/ui/src/components/UserProfile/Web3Section.tsx +++ b/packages/ui/src/components/UserProfile/Web3Section.tsx @@ -7,7 +7,8 @@ import { ProfileSection } from '@/ui/elements/Section'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; import { handleError } from '@/ui/utils/errorHandler'; -import { Badge, Box, Flex, Image, localizationKeys, Text } from '../../customizables'; +import { ProviderIcon } from '../../common'; +import { Badge, Box, Flex, localizationKeys, Text } from '../../customizables'; import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; import { useEnabledThirdPartyProviders } from '../../hooks'; @@ -72,10 +73,11 @@ export const Web3Section = withCardStateProvider( > ({ alignItems: 'center', gap: t.space.$2, width: '100%' })}> {strategyToDisplayData[strategy].iconUrl && ( - {strategyToDisplayData[strategy].name} ({ width: theme.sizes.$4 })} /> )} diff --git a/packages/ui/src/components/UserProfile/__tests__/EnterpriseAccountsSection.test.tsx b/packages/ui/src/components/UserProfile/__tests__/EnterpriseAccountsSection.test.tsx index 9ee0ee40d0e..cabd738fa30 100644 --- a/packages/ui/src/components/UserProfile/__tests__/EnterpriseAccountsSection.test.tsx +++ b/packages/ui/src/components/UserProfile/__tests__/EnterpriseAccountsSection.test.tsx @@ -243,12 +243,12 @@ describe('EnterpriseAccountsSection ', () => { it('renders connection', async () => { const { wrapper } = await createFixtures(withOAuthBuiltInEnterpriseConnection); - const { getByText, getByRole } = render(, { wrapper }); + const { getByText, getByLabelText } = render(, { wrapper }); getByText(/^Enterprise accounts/i); getByText(/google/i); - const img = getByRole('img', { name: /google/i }); - expect(img.getAttribute('src')).toBe('https://img.clerk.com/static/google.svg?width=160'); + const icon = getByLabelText(/google's icon/i); + expect(icon).toBeInTheDocument(); getByText(/test@clerk.com/i); }); }); @@ -260,12 +260,12 @@ describe('EnterpriseAccountsSection ', () => { const { wrapper } = await createFixtures(withOAuthCustomEnterpriseConnection(mockLogoUrl)); - const { getByText, getByRole } = render(, { wrapper }); + const { getByText, getByLabelText } = render(, { wrapper }); getByText(/^Enterprise accounts/i); getByText(/roblox/i); - const img = getByRole('img', { name: /roblox/i }); - expect(img.getAttribute('src')).toContain(mockLogoUrl); + const icon = getByLabelText(/roblox's icon/i); + expect(icon).toBeInTheDocument(); getByText(/test@clerk.com/i); }); }); @@ -288,12 +288,12 @@ describe('EnterpriseAccountsSection ', () => { it('renders connection', async () => { const { wrapper } = await createFixtures(withSamlEnterpriseConnection); - const { getByText, getByRole } = render(, { wrapper }); + const { getByText, getByLabelText } = render(, { wrapper }); getByText(/^Enterprise accounts/i); getByText(/okta workforce/i); - const img = getByRole('img', { name: /okta/i }); - expect(img.getAttribute('src')).toBe('https://img.clerk.com/static/okta.svg?width=160'); + const icon = getByLabelText(/okta workforce's icon/i); + expect(icon).toBeInTheDocument(); getByText(/test@clerk.com/i); }); }); diff --git a/packages/ui/src/elements/SocialButtons.tsx b/packages/ui/src/elements/SocialButtons.tsx index a9578e4b6b9..7b3d8b76186 100644 --- a/packages/ui/src/elements/SocialButtons.tsx +++ b/packages/ui/src/elements/SocialButtons.tsx @@ -4,7 +4,7 @@ import type { OAuthProvider, OAuthStrategy, PhoneCodeChannel, Web3Provider, Web3 import type { Ref } from 'react'; import React, { forwardRef, isValidElement } from 'react'; -import { ProviderInitialIcon } from '../common'; +import { ProviderIcon } from '../common'; import type { LocalizationKey } from '../customizables'; import { Button, @@ -14,7 +14,6 @@ import { Icon, localizationKeys, SimpleButton, - Span, Spinner, Text, useAppearance, @@ -30,13 +29,6 @@ import { distributeStrategiesIntoRows } from './utils'; const SOCIAL_BUTTON_BLOCK_THRESHOLD = 2; const SOCIAL_BUTTON_PRE_TEXT_THRESHOLD = 1; const MAX_STRATEGIES_PER_ROW = 5; -const SUPPORTS_MASK_IMAGE = ['apple', 'github', 'okx_wallet', 'vercel'] as const; - -type SupportsMaskImageProvider = (typeof SUPPORTS_MASK_IMAGE)[number]; - -const supportsMaskImage = (id: OAuthProvider | Web3Provider | PhoneCodeChannel): id is SupportsMaskImageProvider => { - return (SUPPORTS_MASK_IMAGE as readonly string[]).includes(id); -}; export type SocialButtonsProps = React.PropsWithChildren<{ enableOAuthProviders: boolean; @@ -195,39 +187,16 @@ export const SocialButtons = React.memo((props: SocialButtonsRootProps) => { provider: strategyToDisplayData[strategy].name, }); - const imageOrInitial = strategyToDisplayData[strategy].iconUrl ? ( - ({ - display: 'inline-block', - width: theme.sizes.$4, - height: theme.sizes.$4, - maxWidth: '100%', - ...(supportsMaskImage(strategyToDisplayData[strategy].id) - ? { - '--cl-icon-fill': theme.colors.$colorForeground, - backgroundColor: 'var(--cl-icon-fill)', - maskImage: `url(${strategyToDisplayData[strategy].iconUrl})`, - maskSize: 'cover', - maskPosition: 'center', - maskRepeat: 'no-repeat', - } - : { - backgroundImage: `url(${strategyToDisplayData[strategy].iconUrl})`, - backgroundSize: 'cover', - backgroundPosition: 'center', - backgroundRepeat: 'no-repeat', - }), - })} - /> - ) : ( - );