diff --git a/.changeset/account-button-infinite-scroll.md b/.changeset/account-button-infinite-scroll.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/account-button-infinite-scroll.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/account-button-integration.md b/.changeset/account-button-integration.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/account-button-integration.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/account-button.stories.tsx b/packages/swingset/src/stories/account-button.stories.tsx index d9eb22bfb7c..3b3e4b24f63 100644 --- a/packages/swingset/src/stories/account-button.stories.tsx +++ b/packages/swingset/src/stories/account-button.stories.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource @emotion/react */ -import { type AccountButtonProps, AccountButtonView } from '@clerk/ui/mosaic/account/account-button.view'; +import { AccountButtonView, type AccountButtonViewProps } from '@clerk/ui/mosaic/account/account-button.view'; import type { StoryMeta } from '@/lib/types'; @@ -29,7 +29,7 @@ const handlers = { onCreateOrganization: () => {}, onAddAccount: () => {}, onUpgrade: () => {}, -} satisfies Partial; +} satisfies Partial; const preston = { sessionId: 'sess_1', userId: 'user_1', name: 'Preston Booth', email: 'preston@clerk.dev' }; @@ -37,7 +37,6 @@ export function Default(_args: Record) { return ( ) { return ( ) { return ( } | null; let organization: { id: string } | null; let membershipRequests: { count: number }; -let userMemberships: { data: unknown[]; count: number; revalidate: ReturnType }; -let userInvitations: { data: unknown[]; count: number; revalidate: ReturnType }; -let userSuggestions: { data: unknown[]; count: number; revalidate: ReturnType }; +type FakePaginated = { + data: unknown[]; + count: number; + revalidate: ReturnType; + hasNextPage?: boolean; + isFetching?: boolean; +}; +let userMemberships: FakePaginated; +let userInvitations: FakePaginated; +let userSuggestions: FakePaginated; let signedInSessions: FakeSession[]; +let singleSessionMode: boolean; +let controllerOptions: AccountButtonControllerOptions | undefined; let setActive: ReturnType; let signOut: ReturnType; @@ -50,8 +59,11 @@ vi.mock('@clerk/shared/react', async importOriginal => { buildOrganizationProfileUrl: () => '/org-profile', buildCreateOrganizationUrl: () => '/create-org', buildSignInUrl: () => '/sign-in', + buildAfterSignOutUrl: () => '/after-signout', + buildAfterMultiSessionSingleSignOutUrl: () => '/after-single-signout', client: { signedInSessions }, __internal_environment: { + authConfig: { singleSessionMode }, displayConfig: { afterCreateOrganizationUrl: '/after-create', afterSwitchSessionUrl: '/after-switch', @@ -121,6 +133,8 @@ beforeEach(() => { setActive = vi.fn().mockResolvedValue(undefined); signOut = vi.fn().mockResolvedValue(undefined); navigate = vi.fn().mockResolvedValue(undefined); + singleSessionMode = false; + controllerOptions = undefined; }); afterEach(() => { @@ -128,7 +142,7 @@ afterEach(() => { }); function Harness() { - const c = useAccountButtonController(); + const c = useAccountButtonController(controllerOptions); if (c.status !== 'ready') { return {c.status}; } @@ -142,9 +156,14 @@ function Harness() { {String(c.activeOrganizationId)} {String(c.hasOrganizations)} {c.additionalAccounts.map(a => a.userId).join(',')} + {String(Boolean(c.onAddAccount))} + {String(Boolean(c.onSignOutAll))} {JSON.stringify(c.memberships)} {JSON.stringify(c.suggestions)} {JSON.stringify(c.invitations)} + {String(c.hasMoreRows)} + {String(c.isFetchingRows)} + {String(typeof c.loadMoreRef === 'function')} + ); } function activeMembership(data: AccountButtonData): AccountButtonMembership | undefined { @@ -483,10 +141,13 @@ interface RowProps { badge?: ReactNode; trailing?: ReactNode; hoverAction?: ReactNode; + /** Key for this row's own select action; drives its spinner/disabled state. */ + busyKey?: string; } -function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, trailing, hoverAction }: RowProps) { +function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, trailing, hoverAction, busyKey }: RowProps) { const { item, select, name: nameSlot, secondary: secondarySlot } = useRecipe(accountButtonRecipe); + const { loading, disabled } = useBusy(busyKey); const inner = ( <> {inner} @@ -522,7 +184,9 @@ function Row({ name, secondary, shape, imageUrl, onSelect, active, badge, traili {inner} )} - {active ? ( + {loading ? ( + + ) : active ? ( Suggested; } -function InlineButton({ label, onClick }: { label: string; onClick: () => void }) { - const { inlineButton } = useRecipe(accountButtonRecipe); - return ( - - ); -} - -function HoverAction({ onClick }: { onClick: () => void }) { - const { hoverAction } = useRecipe(accountButtonRecipe); - return ( - - ); +function PendingApprovalLabel() { + const { suggestedBadge } = useRecipe(accountButtonRecipe); + return Pending approval; } function AddRow({ label, onClick }: { label: string; onClick: () => void }) { const { add, addIcon } = useRecipe(accountButtonRecipe); + const { disabled } = useBusy(); return ( + ))} ) : null} @@ -679,7 +333,7 @@ function Header() { function WorkspaceList() { const data = useAccountButtonContext(); - const { group } = useRecipe(accountButtonRecipe); + const { group, scroll, loadMore } = useRecipe(accountButtonRecipe); const selectOrg = data.onSelectOrganization; const acceptSuggestion = data.onAcceptSuggestion; const acceptInvitation = data.onAcceptInvitation; @@ -687,60 +341,95 @@ function WorkspaceList() { return (
- signOutSession(data.activeAccount.sessionId)} /> : undefined - } - /> - {data.memberships.map(m => ( - selectOrg(m.organizationId) : undefined} - active={m.organizationId === data.activeOrganizationId} - /> - ))} - {data.suggestions.map(s => ( - } - trailing={ - acceptSuggestion ? ( - acceptSuggestion(s.id)} - /> - ) : undefined - } - /> - ))} - {data.invitations.map(i => ( +
acceptInvitation(i.id)} + shape='circle' + name={data.activeAccount.name} + secondary={data.activeAccount.email} + imageUrl={data.activeAccount.imageUrl} + onSelect={data.onSelectPersonal} + active={data.activeOrganizationId === null} + busyKey={accountBusyKeys.selectPersonal()} + hoverAction={ + signOutSession ? ( + signOutSession(data.activeAccount.sessionId)} + busyKey={accountBusyKeys.signOutSession(data.activeAccount.sessionId)} /> ) : undefined } /> - ))} + {data.memberships.map(m => ( + selectOrg(m.organizationId) : undefined} + active={m.organizationId === data.activeOrganizationId} + busyKey={accountBusyKeys.selectOrganization(m.organizationId)} + /> + ))} + {data.suggestions.map(s => + s.status === 'accepted' ? ( + } + /> + ) : ( + } + trailing={ + acceptSuggestion ? ( + acceptSuggestion(s.id)} + busyKey={accountBusyKeys.acceptSuggestion(s.id)} + /> + ) : undefined + } + /> + ), + )} + {data.invitations.map(i => ( + acceptInvitation(i.id)} + busyKey={accountBusyKeys.acceptInvitation(i.id)} + /> + ) : undefined + } + /> + ))} +
+ {data.hasMoreRows || data.isFetchingRows ? ( +
+ +
+ ) : null} +
+
{data.onCreateOrganization ? ( switchAccount(a.sessionId) : undefined} + busyKey={accountBusyKeys.switchAccount(a.sessionId)} /> ))} {data.onAddAccount ? ( @@ -785,22 +475,24 @@ function AccountsSection() { function Footer() { const data = useAccountButtonContext(); - const { footer, signOutAll, branding } = useRecipe(accountButtonRecipe); + const { footer, branding } = useRecipe(accountButtonRecipe); return (
{data.onSignOutAll ? ( - + ) : null}
Secured by Clerk
@@ -809,7 +501,7 @@ function Footer() { // ─── Public parts ─────────────────────────────────────────────────────────── -export interface AccountButtonRootProps extends AccountButtonData, AccountButtonCallbacks { +export interface AccountButtonRootProps extends AccountButtonData, AccountButtonCallbacks, AccountButtonBusyState { children: ReactNode; open?: boolean; defaultOpen?: boolean; @@ -875,6 +567,31 @@ export function AccountButtonTrigger() { ); } +/** + * Placeholder shown in the trigger's slot while the controller loads, so the sidebar reserves the + * trigger's space and nothing shifts when the real avatar/name land. Non-interactive. + */ +export function AccountButtonTriggerSkeleton() { + const { trigger } = useRecipe(accountButtonRecipe); + return ( +
+ ({ borderRadius: t.rounded.full, flexShrink: 0 })} + /> + +
+ ); +} + /** The popover surface: header, workspace list, additional accounts, and footer. */ export function AccountButtonPopup() { const data = useAccountButtonContext(); @@ -884,7 +601,9 @@ export function AccountButtonPopup() {
- {data.hasOrganizations ? : null} + {data.hasOrganizations || data.suggestions.length > 0 || data.invitations.length > 0 ? ( + + ) : null}