diff --git a/.changeset/connection-description-motion.md b/.changeset/connection-description-motion.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/connection-description-motion.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/docs/motion.md b/packages/mosaic/docs/motion.md index e47bce83d74..2965e1500da 100644 --- a/packages/mosaic/docs/motion.md +++ b/packages/mosaic/docs/motion.md @@ -243,6 +243,10 @@ A swap while open plays out-then-in rather than overlapping: the old message fad `fast`, and the new one's entrance delay holds it until that is done, while the row eases between the two heights on `--cl-ease-enter`. +`Section.CollapsibleDescription` is the same recipe for a row's description line (the +connected account identifier, the wallet address), with the gap var set to +`Section.Content`'s. + ### The fade on the clipped edge A mask on the wrapper, not an element: it needs no background color, works on any diff --git a/packages/mosaic/src/components/section/index.ts b/packages/mosaic/src/components/section/index.ts index 540434b0ca1..9d1b17575ef 100644 --- a/packages/mosaic/src/components/section/index.ts +++ b/packages/mosaic/src/components/section/index.ts @@ -3,6 +3,7 @@ export { sectionCompactStyles } from './section.styles'; export type { SectionActionsProps, SectionBodyProps, + SectionCollapsibleDescriptionProps, SectionContentProps, SectionDescriptionProps, SectionGroupProps, diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index 12499be1226..4cbc6a4d171 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -146,6 +146,14 @@ export const styles = stylex.create({ overflowWrap: 'anywhere', textWrap: 'balance', }, + collapsibleDescription: { + '--_cl-feedback-gap': space['0.5'], + gridTemplateRows: 'max-content', + }, + collapsibleDescriptionText: { + display: 'block', + textWrap: null, + }, actions: { alignItems: 'center', display: 'grid', diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index c1c29f54768..a61757da253 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -1,11 +1,21 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import * as stylex from '@stylexjs/stylex'; -import { render, screen, waitFor, within } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import React from 'react'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { truncationStyles } from '../../styles/typography.styles'; import { HeadingLevelProvider } from '../heading'; import { Section } from './section'; +type Animated = { getAnimations?: () => Animation[] }; + +function holdExits() { + const exit = createDeferredPromise(); + (Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation]; + return exit; +} + const overrides = stylex.create({ root: { containerType: 'inline-size' }, group: { borderWidth: 2 }, @@ -18,6 +28,10 @@ const atoms = (style: stylex.StyleXStyles) => (stylex.props(style).className ?? '').split(' ').filter(name => /^x[a-z0-9]+$/.test(name)); describe('Section', () => { + afterEach(() => { + delete (Element.prototype as Animated).getAnimations; + }); + it('renders a card named by its heading, with every compound part', () => { render( @@ -358,6 +372,96 @@ describe('Section', () => { await waitFor(() => expect(screen.queryByTestId('error')).not.toBeInTheDocument()); }); + describe('CollapsibleDescription', () => { + function Host({ value, truncate }: { value?: string; truncate?: boolean }) { + return ( + + + + Google + + {value} + + + + + ); + } + + it('renders nothing while it has no content', () => { + render(); + + expect(screen.queryByTestId('description')).not.toBeInTheDocument(); + }); + + it('shows content present at mount without an entrance', async () => { + render(); + + const description = screen.getByTestId('description'); + expect(description).toHaveClass('cl-section-description'); + expect(description).toHaveAttribute('data-collapsible'); + expect(description).toHaveAttribute('data-open'); + expect(description).not.toHaveAttribute('data-starting-style'); + expect(description.getAttribute('style')).toContain('transition: none'); + expect(description.getAttribute('style')).toContain('--_cl-feedback-height'); + await waitFor(() => expect(description.getAttribute('style')).not.toContain('transition')); + }); + + it('opens from its starting state on content that arrives after mount', async () => { + const { rerender } = render(); + rerender(); + + const description = screen.getByTestId('description'); + expect(description).toHaveAttribute('data-starting-style'); + expect(description).toHaveTextContent('ada@example.com'); + await waitFor(() => expect(description).not.toHaveAttribute('data-starting-style')); + expect(description).toHaveAttribute('data-open'); + expect(description).not.toHaveAttribute('aria-hidden'); + }); + + it('holds the last content through the exit, then leaves', async () => { + const exit = holdExits(); + const { rerender } = render(); + rerender(); + + const description = screen.getByTestId('description'); + expect(description).toHaveAttribute('data-ending-style'); + expect(description).toHaveAttribute('aria-hidden', 'true'); + expect(description).toHaveTextContent('ada@example.com'); + + await act(async () => { + exit.resolve(); + await exit.promise; + }); + expect(screen.queryByTestId('description')).not.toBeInTheDocument(); + }); + + it('enters again after it has closed', async () => { + const { rerender } = render(); + rerender(); + await waitFor(() => expect(screen.queryByTestId('description')).not.toBeInTheDocument()); + + rerender(); + expect(screen.getByTestId('description')).toHaveAttribute('data-starting-style'); + }); + + it('truncates the text to one line and carries the title', () => { + render( + , + ); + + expect(screen.getByText('ada@example.com')).toHaveClass(...atoms(truncationStyles.singleLine)); + expect(screen.getByTestId('description')).toHaveAttribute('title', 'ada@example.com'); + }); + }); + it('states why a row has no action, with the leading glyph in its own slot', () => { render( diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 1e7a264ec59..7d9fdb84125 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -2,6 +2,7 @@ import { useSafeLayoutEffect } from '@clerk/shared/react'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; +import type { TransitionProps } from '../../primitives/hooks/use-transition'; import { useTransition } from '../../primitives/hooks/use-transition'; import { useRender } from '../../primitives/utils'; import type { MosaicComponentProps } from '../../props'; @@ -29,6 +30,7 @@ export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMe export type SectionContentProps = MosaicComponentProps<'div'>; export type SectionLabelProps = MosaicComponentProps<'div'>; export type SectionDescriptionProps = MosaicComponentProps<'div'>; +export type SectionCollapsibleDescriptionProps = MosaicComponentProps<'div'> & { truncate?: boolean }; export type SectionActionsProps = MosaicComponentProps<'div'>; export type SectionNoteProps = MosaicComponentProps<'div'> & { icon?: React.ReactNode }; export type SectionErrorProps = MosaicComponentProps<'p'>; @@ -244,6 +246,81 @@ const Description = React.forwardRef(fu }); }); +function useSettledEntrance(mounted: boolean, transitionProps: TransitionProps): TransitionProps { + const settled = React.useRef(false); + const entrance = React.useRef(null); + + React.useEffect(() => { + settled.current = true; + }, []); + + if (!mounted) { + entrance.current = null; + } else if (entrance.current === null) { + entrance.current = settled.current; + } + + return entrance.current ? transitionProps : { ...transitionProps, 'data-starting-style': undefined }; +} + +/** + * A `Section.Description` that opens and closes on the content it is given, so a row grows and + * shrinks by a line rather than jumping. Render it with the content — + * `{identifier}` — rather than + * behind a conditional: empty content closes it, and the last content stays in place until it has + * closed. Content present when it first mounts shows at once. `truncate` keeps the text to one + * line with an ellipsis; pass `title` with it so the full text is still reachable. + */ +const CollapsibleDescription = React.forwardRef( + function SectionCollapsibleDescription({ truncate = false, render, xstyle, children, ...rest }, ref) { + const open = hasMessage(children); + const element = React.useRef(null); + const [textElement, setTextElement] = React.useState(null); + const height = useMessageHeight(textElement); + const transition = useTransition({ open, ref: element }); + const transitionProps = useSettledEntrance(transition.mounted, transition.transitionProps); + const content = useHeldMessage(children, open); + const textProps = stylex.props( + reset.base, + feedbackStyles.message, + styles.collapsibleDescriptionText, + truncate && truncationStyles.singleLine, + ); + + return useRender({ + defaultTagName: 'div', + render, + enabled: transition.mounted, + ref: [ref, element], + props: { + ...mergeStyleProps( + themeProps('section-description', { collapsible: true }), + stylex.props( + reset.base, + styles.description, + feedbackStyles.collapse, + feedbackHeight.measured(height), + styles.collapsibleDescription, + xstyle, + ), + { 'aria-hidden': open ? undefined : true, ...transitionProps }, + rest, + ), + children: ( + + {content} + + ), + }, + }); + }, +); + const Actions = React.forwardRef(function SectionActions( { render, xstyle, ...rest }, ref, @@ -364,6 +441,7 @@ export const Section = { Content, Label, Description, + CollapsibleDescription, Actions, Note, Error: SectionError, diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-account-row.view.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-account-row.view.test.tsx new file mode 100644 index 00000000000..79f3c2a4845 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-account-row.view.test.tsx @@ -0,0 +1,142 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { UserProfileConnectedAccountRowView } from '../user-profile-connected-account-row.view'; +import type { + UserProfileConnectedAccount, + UserProfileConnectionProvider, +} from '../user-profile-connected-accounts-section.types'; + +type Animated = { getAnimations?: () => Animation[] }; + +function holdExits() { + const exit = createDeferredPromise(); + (Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation]; + return exit; +} + +const provider: UserProfileConnectionProvider = { id: 'oauth_google', provider: 'Google' }; +const account: UserProfileConnectedAccount = { + id: 'idn_google', + providerId: 'oauth_google', + provider: 'Google', + identifier: 'ada@example.com', + status: 'connected', +}; + +const description = () => screen.getByText('ada@example.com').closest('.cl-section-description'); + +describe('UserProfileConnectedAccountRowView', () => { + afterEach(() => { + delete (Element.prototype as Animated).getAnimations; + }); + + it('offers Connect for a provider that is not connected', () => { + const onConnect = vi.fn(); + render( + , + ); + + expect(screen.getByText('Google')).toBeInTheDocument(); + expect(screen.queryByText('ada@example.com')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage Google' })).not.toBeInTheDocument(); + screen.getByRole('button', { name: 'Connect Google' }).click(); + expect(onConnect).toHaveBeenCalledExactlyOnceWith('oauth_google'); + }); + + it('shows a first-load identifier without an entrance', () => { + render(); + + expect(description()).toHaveAttribute('data-open'); + expect(description()).not.toHaveAttribute('data-starting-style'); + expect(description()).toHaveAttribute('title', 'ada@example.com'); + }); + + it('becomes the connected row in place when its provider connects', () => { + const { rerender } = render( + , + ); + const row = screen.getByText('Google').closest('.cl-section-row'); + + rerender( + , + ); + + expect(screen.getByText('Google').closest('.cl-section-row')).toBe(row); + expect(description()).toHaveAttribute('data-starting-style'); + expect(screen.queryByRole('button', { name: 'Connect Google' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage Google' })).toBeInTheDocument(); + }); + + it('offers Connect again in place while the identifier collapses out', async () => { + const exit = holdExits(); + const { rerender } = render( + , + ); + const row = screen.getByText('Google').closest('.cl-section-row'); + + rerender( + , + ); + + expect(screen.getByText('Google').closest('.cl-section-row')).toBe(row); + expect(description()).toHaveAttribute('data-ending-style'); + expect(description()).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByRole('button', { name: 'Connect Google' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage Google' })).not.toBeInTheDocument(); + + await act(async () => { + exit.resolve(); + await exit.promise; + }); + expect(screen.queryByText('ada@example.com')).not.toBeInTheDocument(); + }); + + it('holds the identifier through its exit once the account loses it', async () => { + const exit = holdExits(); + const { rerender } = render(); + + rerender(); + expect(description()).toHaveAttribute('data-ending-style'); + + await act(async () => { + exit.resolve(); + await exit.promise; + }); + expect(screen.queryByText('ada@example.com')).not.toBeInTheDocument(); + }); + + it('shows the provider connect error and the account errors on the same row', () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect.'); + + rerender( + , + ); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Try again.'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.view.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.view.test.tsx new file mode 100644 index 00000000000..6a0f21481bf --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/__tests__/user-profile-connected-accounts-section.view.test.tsx @@ -0,0 +1,180 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + UserProfileConnectedAccount, + UserProfileConnectionProvider, +} from '../user-profile-connected-accounts-section.types'; +import { UserProfileConnectedAccountsSectionView } from '../user-profile-connected-accounts-section.view'; + +type Animated = { getAnimations?: () => Animation[] }; + +function holdExits() { + const exit = createDeferredPromise(); + (Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation]; + return exit; +} + +const google: UserProfileConnectionProvider = { id: 'oauth_google', provider: 'Google' }; +const connectedGoogle: UserProfileConnectedAccount = { + id: 'idn_google', + providerId: 'oauth_google', + provider: 'Google', + identifier: 'ada@example.com', + status: 'connected', +}; +const connectedGithub: UserProfileConnectedAccount = { + id: 'idn_github', + providerId: 'oauth_github', + provider: 'GitHub', + identifier: 'ada', + status: 'connected', +}; + +const rowOf = (name: string) => screen.getByText(name).closest('.cl-section-row'); +const rowNames = () => + Array.from(document.querySelectorAll('.cl-section-row .cl-section-label')).map(label => label.textContent); + +describe('UserProfileConnectedAccountsSectionView', () => { + afterEach(() => { + delete (Element.prototype as Animated).getAnimations; + }); + + it('lists connected accounts before providers to connect', () => { + render( + , + ); + + expect(rowNames()).toEqual(['GitHub', 'Google']); + expect(screen.getByRole('button', { name: 'Connect Google' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^Manage/ })).not.toBeInTheDocument(); + }); + + it('connects a provider in its own row, expanding the identifier', () => { + const { rerender } = render( + , + ); + const row = rowOf('Google'); + + rerender( + , + ); + + expect(rowOf('Google')).toBe(row); + expect(rowNames()).toEqual(['GitHub', 'Google']); + expect(row).not.toHaveAttribute('data-starting-style'); + expect(screen.getByText('ada@example.com').closest('.cl-section-description')).toHaveAttribute( + 'data-starting-style', + ); + expect(within(row as HTMLElement).getByRole('button', { name: 'Manage Google' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect Google' })).not.toBeInTheDocument(); + }); + + it('keeps a connected provider in place when the model lists it first', () => { + const { rerender } = render( + , + ); + + rerender( + , + ); + + expect(rowNames()).toEqual(['GitHub', 'Google']); + }); + + it('turns a removed account back into its Connect row while the identifier collapses', async () => { + const exit = holdExits(); + const { rerender } = render( + , + ); + const row = rowOf('Google'); + + rerender( + , + ); + + expect(rowOf('Google')).toBe(row); + expect(rowNames()).toEqual(['Google', 'GitHub']); + expect(screen.getByText('ada@example.com').closest('.cl-section-description')).toHaveAttribute('data-ending-style'); + expect(within(row as HTMLElement).getByRole('button', { name: 'Connect Google' })).toBeInTheDocument(); + + await act(async () => { + exit.resolve(); + await exit.promise; + }); + expect(screen.queryByText('ada@example.com')).not.toBeInTheDocument(); + }); + + it('confirms removal and moves focus to the next connected account', async () => { + const user = userEvent.setup(); + const onRemove = vi.fn(() => Promise.resolve()); + const { rerender } = render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + expect(onRemove).toHaveBeenCalledExactlyOnceWith('idn_google'); + + rerender( + , + ); + await waitFor(() => expect(screen.getByRole('button', { name: 'Manage GitHub' })).toHaveFocus()); + }); + + it('offers no Connect rows without a connect handler', () => { + render( + , + ); + + expect(rowNames()).toEqual(['GitHub']); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx index d6ee3036773..4be1efc6b3e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx @@ -7,6 +7,7 @@ import { Badge } from '../../../components/badge'; import { SubmitButton } from '../../../components/button'; import { Icon, IconFrame } from '../../../components/icon'; import { Section } from '../../../components/section'; +import type { MosaicMessages } from '../../../localization'; import { fill, useMessages } from '../../../localization'; import { truncationStyles } from '../../../styles/typography.styles'; import { styles } from './user-profile-connected-accounts-section.styles'; @@ -46,116 +47,114 @@ function ProviderMedia({ provider }: { provider: ConnectedAccountProviderDisplay ); } -export function UserProfileConnectProviderRowView({ - provider, - isPending, +function accountActions({ + account, + m, isDisabled, - onConnect, + onReconnect, + onRemove, }: { - provider: UserProfileConnectionProvider; - isPending: boolean; + account: UserProfileConnectedAccount; + m: MosaicMessages['userProfileConnectedAccounts']; isDisabled: boolean; - onConnect: (id: string) => void; -}) { - const m = useMessages('userProfileConnectedAccounts'); - return ( - - - - - - - {provider.provider} - - - - - onConnect(provider.id)} - > - {m.connect} - - - - - {provider.connectError} - - ); + onReconnect?: (id: string) => void; + onRemove?: (account: UserProfileConnectedAccount) => void; +}): ActionMenuAction[] { + const actions: ActionMenuAction[] = []; + if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) { + actions.push({ + label: account.status === 'reconnect' ? m.reconnect : m.tryAgain, + onClick: () => onReconnect(account.id), + }); + } + if (onRemove) { + actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(account) }); + } + return actions; } export function UserProfileConnectedAccountRowView({ account, + provider, triggerRef, + isPending = false, isDisabled = false, + onConnect, onReconnect, onRemove, }: { - account: UserProfileConnectedAccount; + account?: UserProfileConnectedAccount; + provider?: UserProfileConnectionProvider; triggerRef?: Ref; + isPending?: boolean; isDisabled?: boolean; + onConnect?: (id: string) => void; onReconnect?: (id: string) => void; onRemove?: (account: UserProfileConnectedAccount) => void; }) { const m = useMessages('userProfileConnectedAccounts'); - const actions: ActionMenuAction[] = []; - if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) { - actions.push({ - label: account.status === 'reconnect' ? m.reconnect : m.tryAgain, - onClick: () => onReconnect(account.id), - }); - } - if (onRemove) { - actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(account) }); + const display = account ?? provider; + if (!display) { + return null; } + const actions = account ? accountActions({ account, m, isDisabled, onReconnect, onRemove }) : []; + return ( - + - + - {account.provider} + {display.provider} - {account.status === 'reconnect' ? {m.disconnected} : null} + {account?.status === 'reconnect' ? {m.disconnected} : null} - {account.identifier ? ( - - {account.identifier} - - ) : null} + + {account?.identifier} + - {actions.length > 0 ? ( + {account ? ( + actions.length > 0 ? ( + + + + ) : null + ) : provider && onConnect ? ( - + onConnect(provider.id)} + > + {m.connect} + + ) : null} - {account.reconnectError} - {account.status === 'error' ? account.verificationError : undefined} + {provider?.connectError} + {account?.reconnectError} + {account?.status === 'error' ? account.verificationError : undefined} ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts index 2a2af35966e..de121e78a69 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts @@ -157,9 +157,11 @@ function toAccountRow( ): UserProfileConnectedAccount { const error = account.verification?.error; const { status } = recoveryFor(account, scopes, providers); + const provider = providers.find(provider => provider.provider === account.provider); return { id: account.id, - ...(providers.find(provider => provider.provider === account.provider)?.display ?? { provider: account.provider }), + providerId: provider?.strategy, + ...(provider?.display ?? { provider: account.provider }), identifier: account.username || account.emailAddress || undefined, status, verificationError: status === 'error' ? error?.longMessage : undefined, diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts index adcd5ffc3d5..1f9ed5e6528 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.types.ts @@ -13,6 +13,7 @@ export interface UserProfileConnectionProvider extends ConnectedAccountProviderD export interface UserProfileConnectedAccount extends ConnectedAccountProviderDisplay { id: string; + providerId?: string; identifier?: string; status: 'connected' | 'reconnect' | 'error'; verificationError?: string; diff --git a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx index 7a806d89c06..1383f505ef0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view.tsx @@ -3,14 +3,13 @@ import { useMemo, useRef } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Section } from '../../../components/section'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { useProviderRows } from '../../../hooks/use-provider-rows'; import { fill, useMessages } from '../../../localization'; -import { - UserProfileConnectedAccountRowView, - UserProfileConnectProviderRowView, -} from './user-profile-connected-account-row.view'; +import { UserProfileConnectedAccountRowView } from './user-profile-connected-account-row.view'; import type { UserProfileConnectedAccount, UserProfileConnectedAccountsSectionViewProps, + UserProfileConnectionProvider, } from './user-profile-connected-accounts-section.types'; export type { @@ -19,6 +18,9 @@ export type { UserProfileConnectionProvider, } from './user-profile-connected-accounts-section.types'; +const providerOf = (account: UserProfileConnectedAccount) => account.providerId; +const noProviders: UserProfileConnectionProvider[] = []; + export function UserProfileConnectedAccountsSectionView({ accounts, fallbackFocus, @@ -42,6 +44,7 @@ export function UserProfileConnectedAccountsSectionView({ const removeAccount = useMemo(() => Confirmation.createHandle(), []); const hasRows = accounts.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); const isBusy = pendingId !== undefined; + const rows = useProviderRows(accounts, onConnect ? availableProviders : noProviders, providerOf); return ( <> @@ -57,27 +60,19 @@ export function UserProfileConnectedAccountsSectionView({ - {accounts.map(account => ( + {rows.map(({ key, connected: account, provider }) => ( removeAccount.open(account) : undefined} /> ))} - {onConnect - ? availableProviders.map(provider => ( - - )) - : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallet-row.view.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallet-row.view.test.tsx new file mode 100644 index 00000000000..8b1c0db4681 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallet-row.view.test.tsx @@ -0,0 +1,129 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { UserProfileWeb3WalletRowView } from '../user-profile-web3-wallet-row.view'; +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.types'; + +type Animated = { getAnimations?: () => Animation[] }; + +function holdExits() { + const exit = createDeferredPromise(); + (Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation]; + return exit; +} + +const address = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; +const shortAddress = '0x71C7...976F'; +const provider: UserProfileWeb3Provider = { id: 'web3_metamask_signature', provider: 'MetaMask' }; +const wallet: UserProfileWeb3Wallet = { + id: 'wallet_1', + providerId: 'web3_metamask_signature', + provider: 'MetaMask', + address, + isVerified: true, +}; + +const description = () => screen.getByText(shortAddress).closest('.cl-section-description'); + +describe('UserProfileWeb3WalletRowView', () => { + afterEach(() => { + delete (Element.prototype as Animated).getAnimations; + }); + + it('offers Connect for a provider that is not connected', () => { + const onConnect = vi.fn(); + render( + , + ); + + expect(screen.getByText('MetaMask')).toBeInTheDocument(); + expect(screen.queryByText(shortAddress)).not.toBeInTheDocument(); + screen.getByRole('button', { name: 'Connect MetaMask' }).click(); + expect(onConnect).toHaveBeenCalledExactlyOnceWith('web3_metamask_signature'); + }); + + it('shows a first-load address without an entrance', () => { + render(); + + expect(description()).toHaveAttribute('data-open'); + expect(description()).not.toHaveAttribute('data-starting-style'); + expect(description()).toHaveAttribute('title', address); + }); + + it('becomes the connected row in place when its provider connects', () => { + const { rerender } = render( + , + ); + const row = screen.getByText('MetaMask').closest('.cl-section-row'); + + rerender( + , + ); + + expect(screen.getByText('MetaMask').closest('.cl-section-row')).toBe(row); + expect(description()).toHaveAttribute('data-starting-style'); + expect(screen.getByText('Primary')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect MetaMask' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Manage MetaMask' })).toBeInTheDocument(); + }); + + it('offers Connect again in place while the address collapses out', async () => { + const exit = holdExits(); + const { rerender } = render( + , + ); + const row = screen.getByText('MetaMask').closest('.cl-section-row'); + + rerender( + , + ); + + expect(screen.getByText('MetaMask').closest('.cl-section-row')).toBe(row); + expect(description()).toHaveAttribute('data-ending-style'); + expect(description()).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeInTheDocument(); + + await act(async () => { + exit.resolve(); + await exit.promise; + }); + expect(screen.queryByText(shortAddress)).not.toBeInTheDocument(); + }); + + it('labels a wallet without a provider by its address, with no description', () => { + render(); + + expect(screen.getByText(shortAddress)).toBeInTheDocument(); + expect(screen.getByText(shortAddress).closest('.cl-section-description')).toBeNull(); + }); + + it('shows the provider connect error and the wallet primary error on the same row', () => { + const { rerender } = render( + , + ); + expect(screen.getByRole('alert')).toHaveTextContent('Wallet extension not found.'); + + rerender(); + expect(screen.getByText('Unverified')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Not primary.'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.view.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.view.test.tsx new file mode 100644 index 00000000000..0fe4467496e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/__tests__/user-profile-web3-wallets-section.view.test.tsx @@ -0,0 +1,144 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from '../user-profile-web3-wallets-section.types'; +import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-section.view'; + +type Animated = { getAnimations?: () => Animation[] }; + +function holdExits() { + const exit = createDeferredPromise(); + (Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation]; + return exit; +} + +const metamask: UserProfileWeb3Provider = { id: 'web3_metamask_signature', provider: 'MetaMask' }; +const coinbase: UserProfileWeb3Provider = { id: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet' }; +const metamaskWallet: UserProfileWeb3Wallet = { + id: 'wallet_1', + providerId: 'web3_metamask_signature', + provider: 'MetaMask', + address: '0x71C7656EC7ab88b098defB751B7401B5f6d8976F', + isPrimary: true, + isVerified: true, +}; +const coinbaseWallet: UserProfileWeb3Wallet = { + id: 'wallet_2', + providerId: 'web3_coinbase_wallet_signature', + provider: 'Coinbase Wallet', + address: '0x1234567890abcdef1234567890abcdef12345678', + isVerified: true, +}; + +const rowOf = (name: string) => screen.getByText(name).closest('.cl-section-row'); +const rowNames = () => + Array.from(document.querySelectorAll('.cl-section-row .cl-section-label > span:first-child')).map( + label => label.textContent, + ); + +describe('UserProfileWeb3WalletsSectionView', () => { + afterEach(() => { + delete (Element.prototype as Animated).getAnimations; + }); + + it('connects a provider in its own row, expanding the address', () => { + const { rerender } = render( + , + ); + const row = rowOf('Coinbase Wallet'); + expect(rowNames()).toEqual(['MetaMask', 'Coinbase Wallet']); + + rerender( + , + ); + + expect(rowOf('Coinbase Wallet')).toBe(row); + expect(rowNames()).toEqual(['MetaMask', 'Coinbase Wallet']); + expect(row).not.toHaveAttribute('data-starting-style'); + expect(screen.getByText('0x1234...5678').closest('.cl-section-description')).toHaveAttribute('data-starting-style'); + expect(within(row as HTMLElement).getByRole('button', { name: 'Manage Coinbase Wallet' })).toBeInTheDocument(); + }); + + it('turns a removed wallet back into its Connect row while the address collapses', async () => { + const exit = holdExits(); + const { rerender } = render( + , + ); + const row = rowOf('MetaMask'); + + rerender( + , + ); + + expect(rowOf('MetaMask')).toBe(row); + expect(rowNames()).toEqual(['MetaMask', 'Coinbase Wallet']); + expect(screen.getByText('0x71C7...976F').closest('.cl-section-description')).toHaveAttribute('data-ending-style'); + expect(within(row as HTMLElement).getByRole('button', { name: 'Connect MetaMask' })).toBeInTheDocument(); + + await act(async () => { + exit.resolve(); + await exit.promise; + }); + expect(screen.queryByText('0x71C7...976F')).not.toBeInTheDocument(); + }); + + it('keeps a second wallet of the same provider on its own row', () => { + render( + , + ); + + expect(screen.getAllByRole('button', { name: 'Manage MetaMask' })).toHaveLength(2); + expect(rowNames()).toEqual(['MetaMask', 'MetaMask', 'Coinbase Wallet']); + }); + + it('keeps an unverified wallet beside the Connect row for its provider', () => { + const { rerender } = render( + , + ); + const walletRow = screen.getByText('Unverified').closest('.cl-section-row'); + expect(rowNames()).toEqual(['MetaMask', 'MetaMask']); + expect(screen.getByRole('button', { name: 'Connect MetaMask' })).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByText('Primary').closest('.cl-section-row')).toBe(walletRow); + expect(rowNames()).toEqual(['MetaMask']); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx index f77d287bbc7..4fda25ccfe0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallet-row.view.tsx @@ -14,6 +14,7 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-prof export function UserProfileWeb3WalletRowView({ wallet, + provider, triggerRef, isPending = false, isDisabled = false, @@ -21,7 +22,8 @@ export function UserProfileWeb3WalletRowView({ onSetPrimary, onRemove, }: { - wallet: UserProfileWeb3Wallet | UserProfileWeb3Provider; + wallet?: UserProfileWeb3Wallet; + provider?: UserProfileWeb3Provider; triggerRef?: Ref; isPending?: boolean; isDisabled?: boolean; @@ -30,23 +32,23 @@ export function UserProfileWeb3WalletRowView({ onRemove?: (wallet: UserProfileWeb3Wallet) => void; }) { const m = useMessages('userProfileWeb3Wallets'); - const iconUrl = wallet.iconUrl?.trim(); - const linkedWallet = 'address' in wallet ? wallet : undefined; - const address = linkedWallet?.address; + const name = wallet?.provider || provider?.provider; + const iconUrl = (wallet?.iconUrl ?? provider?.iconUrl)?.trim(); + const address = wallet?.address; const shortAddress = address && (address.length <= 10 ? address : `${address.slice(0, 6)}...${address.slice(-4)}`); const actions: ActionMenuAction[] = []; - if (linkedWallet && !linkedWallet.isPrimary && linkedWallet.isVerified && onSetPrimary) { + if (wallet && !wallet.isPrimary && wallet.isVerified && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(wallet.id) }); } - if (linkedWallet && onRemove && linkedWallet.canRemove !== false) { - actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(linkedWallet) }); + if (wallet && onRemove && wallet.canRemove !== false) { + actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(wallet) }); } return ( - + - {wallet.provider || iconUrl ? ( + {name || iconUrl ? ( {iconUrl ? ( @@ -61,7 +63,7 @@ export function UserProfileWeb3WalletRowView({ aria-hidden {...stylex.props(styles.fallback)} > - {wallet.provider?.trim().charAt(0).toUpperCase()} + {name?.trim().charAt(0).toUpperCase()} )} @@ -70,24 +72,33 @@ export function UserProfileWeb3WalletRowView({ - {wallet.provider || shortAddress} + {name || shortAddress} - {linkedWallet?.isPrimary ? {m.primary} : null} - {linkedWallet && !linkedWallet.isVerified ? {m.unverified} : null} + {wallet?.isPrimary ? {m.primary} : null} + {wallet && !wallet.isVerified ? {m.unverified} : null} - {wallet.provider && address ? ( - - {shortAddress} - - ) : null} + + {name && address ? shortAddress : undefined} + - {onConnect ? ( + {wallet ? ( + actions.length > 0 ? ( + + + + ) : null + ) : provider && onConnect ? ( onConnect(wallet.id)} + aria-label={fill(m.connectLabel, { provider: provider.provider })} + onClick={() => onConnect(provider.id)} > {m.connect} - ) : actions.length > 0 ? ( - - - ) : null} - {'connectError' in wallet ? wallet.connectError : undefined} - {linkedWallet?.primaryError} + {provider?.connectError} + {wallet?.primaryError} ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts index a93be83dbcd..663fe0a20a4 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.model.ts @@ -74,6 +74,7 @@ export function projectWeb3Wallets({ const provider = WEB3_PROVIDERS.find(candidate => candidate.strategy === wallet.verification.strategy); return { id: wallet.id, + providerId: provider?.strategy, address: wallet.web3Wallet, provider: provider?.name, iconUrl: provider ? iconImageUrl(provider.provider) : undefined, diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts index cc553c1ea4f..7584001450a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.types.ts @@ -10,6 +10,7 @@ export interface UserProfileWeb3Provider { export interface UserProfileWeb3Wallet { id: string; + providerId?: string; address: string; provider?: string; iconUrl?: string; diff --git a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx index 644961421bd..9a592270588 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view.tsx @@ -3,14 +3,19 @@ import { useMemo, useRef } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Section } from '../../../components/section'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { useProviderRows } from '../../../hooks/use-provider-rows'; import { fill, useMessages } from '../../../localization'; import { truncateWithEndVisible } from '../../../utils/truncate-text-with-end-visible'; import { UserProfileWeb3WalletRowView } from './user-profile-web3-wallet-row.view'; import type { + UserProfileWeb3Provider, UserProfileWeb3Wallet, UserProfileWeb3WalletsSectionViewProps, } from './user-profile-web3-wallets-section.types'; +const providerOf = (wallet: UserProfileWeb3Wallet) => wallet.providerId; +const noProviders: UserProfileWeb3Provider[] = []; + export function UserProfileWeb3WalletsSectionView({ wallets, fallbackFocus, @@ -34,6 +39,7 @@ export function UserProfileWeb3WalletsSectionView({ const removeWallet = useMemo(() => Confirmation.createHandle(), []); const hasRows = wallets.length > 0 || (availableProviders.length > 0 && Boolean(onConnect)); const isBusy = pendingId !== undefined; + const rows = useProviderRows(wallets, onConnect ? availableProviders : noProviders, providerOf); return ( <> @@ -49,27 +55,19 @@ export function UserProfileWeb3WalletsSectionView({ - {wallets.map(wallet => ( + {rows.map(({ key, connected: wallet, provider }) => ( removeWallet.open(wallet) : undefined} /> ))} - {onConnect - ? availableProviders.map(provider => ( - - )) - : null} diff --git a/packages/mosaic/src/hooks/__tests__/use-provider-rows.test.tsx b/packages/mosaic/src/hooks/__tests__/use-provider-rows.test.tsx new file mode 100644 index 00000000000..ebbf1fc7b97 --- /dev/null +++ b/packages/mosaic/src/hooks/__tests__/use-provider-rows.test.tsx @@ -0,0 +1,63 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useProviderRows } from '../use-provider-rows'; + +type Wallet = { id: string; providerId?: string }; +type Provider = { id: string }; + +const providerOf = (wallet: Wallet) => wallet.providerId; +const keys = (rows: Array<{ key: string }>) => rows.map(row => row.key); + +describe('useProviderRows', () => { + it('keys the first connected item of a provider by the provider and the rest by their own id', () => { + const { result } = renderHook(() => + useProviderRows( + [{ id: 'wallet_1', providerId: 'metamask' }, { id: 'wallet_2', providerId: 'metamask' }, { id: 'wallet_3' }], + [{ id: 'coinbase' }], + providerOf, + ), + ); + + expect(keys(result.current)).toEqual(['metamask', 'wallet_2', 'wallet_3', 'coinbase']); + expect(result.current[0]?.connected?.id).toBe('wallet_1'); + expect(result.current[3]?.provider?.id).toBe('coinbase'); + }); + + it('keeps the offered row where it is when it connects, and when it disconnects again', () => { + const { result, rerender } = renderHook( + ({ wallets, providers }: { wallets: Wallet[]; providers: Provider[] }) => + useProviderRows(wallets, providers, providerOf), + { initialProps: { wallets: [{ id: 'wallet_1', providerId: 'metamask' }], providers: [{ id: 'coinbase' }] } }, + ); + + rerender({ + wallets: [ + { id: 'wallet_1', providerId: 'metamask' }, + { id: 'wallet_2', providerId: 'coinbase' }, + ], + providers: [], + }); + expect(keys(result.current)).toEqual(['metamask', 'coinbase']); + expect(result.current[1]?.connected?.id).toBe('wallet_2'); + + rerender({ wallets: [{ id: 'wallet_2', providerId: 'coinbase' }], providers: [{ id: 'metamask' }] }); + expect(keys(result.current)).toEqual(['metamask', 'coinbase']); + expect(result.current[0]?.provider?.id).toBe('metamask'); + expect(result.current[1]?.connected?.id).toBe('wallet_2'); + }); + + it('leaves an item on its own id while its provider is still offered', () => { + const { result, rerender } = renderHook( + ({ wallets, providers }: { wallets: Wallet[]; providers: Provider[] }) => + useProviderRows(wallets, providers, providerOf), + { + initialProps: { wallets: [{ id: 'wallet_1', providerId: 'metamask' }], providers: [{ id: 'metamask' }] }, + }, + ); + expect(keys(result.current)).toEqual(['wallet_1', 'metamask']); + + rerender({ wallets: [{ id: 'wallet_1', providerId: 'metamask' }], providers: [] }); + expect(keys(result.current)).toEqual(['wallet_1']); + }); +}); diff --git a/packages/mosaic/src/hooks/use-provider-rows.ts b/packages/mosaic/src/hooks/use-provider-rows.ts new file mode 100644 index 00000000000..8ef38b7060e --- /dev/null +++ b/packages/mosaic/src/hooks/use-provider-rows.ts @@ -0,0 +1,49 @@ +import { useRef } from 'react'; + +import { useStableOrder } from '../primitives/hooks'; + +export interface ProviderRow { + key: string; + connected?: Connected; + provider?: Provider; +} + +const byKey = (row: { key: string }) => row.key; + +/** + * One row per provider across its connected and offered states. A connected item takes its + * provider's id as its key when that provider is no longer offered, so the row that offered + * "Connect" is the row that shows the connection, and a removed connection becomes the offer + * again in place. Further items of the same provider, and items whose provider is still offered, + * keep their own id. A key is kept for as long as its item is present, and rows keep the order + * they were first shown in. + */ +export function useProviderRows( + connected: Connected[], + providers: Provider[], + providerOf: (item: Connected) => string | undefined, +): Array> { + const claims = useRef(new Map()); + const taken = new Set(providers.map(provider => provider.id)); + const present = new Set(); + const rows: Array> = []; + + for (const item of connected) { + const wanted = claims.current.get(item.id) ?? providerOf(item) ?? item.id; + const key = taken.has(wanted) ? item.id : wanted; + taken.add(key); + claims.current.set(item.id, key); + present.add(item.id); + rows.push({ key, connected: item }); + } + for (const id of claims.current.keys()) { + if (!present.has(id)) { + claims.current.delete(id); + } + } + for (const provider of providers) { + rows.push({ key: provider.id, provider }); + } + + return useStableOrder(rows, byKey); +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts index 7400dedd713..8b51627f866 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-connected-accounts.ts @@ -9,6 +9,7 @@ import { chaosEmail, chaosText } from '@/lib/chaos'; export const connectedAccount: UserProfileConnectedAccount = { id: 'google', + providerId: 'google', provider: 'Google', identifier: 'test@example.com', iconUrl: 'https://img.clerk.com/static/google.svg', @@ -52,6 +53,7 @@ export function useConnectedAccountsFixture({ ...current, { id: provider.id, + providerId: provider.id, provider: provider.provider, iconUrl: provider.iconUrl, identifier: 'test@example.com', diff --git a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts index 01e9d9bf5b0..985e6209ac5 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-web3-wallets.ts @@ -7,10 +7,6 @@ import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosText } from '@/lib/chaos'; -interface DemoWallet extends UserProfileWeb3Wallet { - providerId?: string; -} - const providers: UserProfileWeb3Provider[] = [ { id: 'web3_metamask_signature', provider: 'MetaMask', iconUrl: 'https://img.clerk.com/static/metamask.svg' }, { @@ -20,7 +16,7 @@ const providers: UserProfileWeb3Provider[] = [ }, ]; -export const primaryWallet: DemoWallet = { +export const primaryWallet: UserProfileWeb3Wallet = { id: 'wallet_1', providerId: 'web3_metamask_signature', provider: 'MetaMask', @@ -30,7 +26,7 @@ export const primaryWallet: DemoWallet = { isVerified: true, }; -export const secondaryWallet: DemoWallet = { +export const secondaryWallet: UserProfileWeb3Wallet = { id: 'wallet_2', providerId: 'web3_coinbase_wallet_signature', provider: 'Coinbase Wallet', @@ -45,7 +41,7 @@ export function useWeb3WalletsFixture({ primaryError = false, removalState, }: { - initialWallets?: DemoWallet[]; + initialWallets?: UserProfileWeb3Wallet[]; availableProviders?: UserProfileWeb3Provider[]; primaryError?: boolean; removalState?: 'pending' | 'error'; diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index df4865d6a9f..abe011087c6 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -44,6 +44,11 @@ list of values, one `Section.Item` per entry, and each item becomes a list item. optional media, flexible content, and trailing actions. `Section.Media` accepts `sm`, `md`, `lg`, and `xl` sizes. +`Section.CollapsibleDescription` is a description that opens and closes on its content. Render it +with the content rather than behind a conditional: empty content closes it, and the last content +stays in place until it has closed. Content present when it first mounts shows at once. `truncate` +keeps the text to one line with an ellipsis; pass `title` with it so the full text stays reachable. + When a row offers no action, `Section.Note` takes the place of its `Section.Actions` to say why — it sits directly in `Section.Item` and holds the trailing slot itself. Its `icon` prop renders into a fixed leading slot sized to the text, for a provider logo or a lock. @@ -59,22 +64,23 @@ title. See [Panel](/components/panel) and [Heading](/components/heading). ## Parts -| Part | Element | Class | Notes | -| --------------------- | ----------- | ------------------------- | --------------------------------------------------------------- | -| `Section.Root` | `
` | `.cl-section` | Stacks groups. Unnamed unless given `aria-label`. | -| `Section.Group` | `
` | `.cl-section-group` | Header and card, with `role='group'` named by its title. | -| `Section.Header` | `
` | `.cl-section-header` | Content and optional actions. | -| `Section.Title` | heading | `.cl-section-title` | The group's heading. | -| `Section.Body` | `
` | `.cl-section-body` | The card under the header. | -| `Section.Row` | `
` | `.cl-section-row` | One setting. | -| `Section.Items` | `
    ` | `.cl-section-items` | A list of values. | -| `Section.Item` | `
    ` | `.cl-section-item` | Media, content, and actions on one line. `
  • ` inside `Items`. | -| `Section.Media` | `
    ` | `.cl-section-media` | Reflects `data-size`. | -| `Section.Content` | `
    ` | `.cl-section-content` | Label or title, and description. | -| `Section.Label` | `
    ` | `.cl-section-label` | | -| `Section.Description` | `
    ` | `.cl-section-description` | | -| `Section.Actions` | `
    ` | `.cl-section-actions` | | -| `Section.Error` | `

    ` | `.cl-section-error` | A row-level message, placed beside the item inside the row. | +| Part | Element | Class | Notes | +| -------------------------------- | ----------- | ------------------------- | ---------------------------------------------------------------------------------------------- | +| `Section.Root` | `

    ` | `.cl-section` | Stacks groups. Unnamed unless given `aria-label`. | +| `Section.Group` | `
    ` | `.cl-section-group` | Header and card, with `role='group'` named by its title. | +| `Section.Header` | `
    ` | `.cl-section-header` | Content and optional actions. | +| `Section.Title` | heading | `.cl-section-title` | The group's heading. | +| `Section.Body` | `
    ` | `.cl-section-body` | The card under the header. | +| `Section.Row` | `
    ` | `.cl-section-row` | One setting. | +| `Section.Items` | `
      ` | `.cl-section-items` | A list of values. | +| `Section.Item` | `
      ` | `.cl-section-item` | Media, content, and actions on one line. `
    • ` inside `Items`. | +| `Section.Media` | `
      ` | `.cl-section-media` | Reflects `data-size`. | +| `Section.Content` | `
      ` | `.cl-section-content` | Label or title, and description. | +| `Section.Label` | `
      ` | `.cl-section-label` | | +| `Section.Description` | `
      ` | `.cl-section-description` | | +| `Section.CollapsibleDescription` | `
      ` | `.cl-section-description` | Reflects `data-collapsible`. Opens and closes on its content; `truncate` keeps it to one line. | +| `Section.Actions` | `
      ` | `.cl-section-actions` | | +| `Section.Error` | `

      ` | `.cl-section-error` | A row-level message, placed beside the item inside the row. | Every part takes `xstyle`, `ref`, and `render` along with its element's props. Items and content inside `Section.Items` reflect `data-nested`.