diff --git a/.changeset/mosaic-user-button-pending-announcement.md b/.changeset/mosaic-user-button-pending-announcement.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-button-pending-announcement.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx index cf473dab094..ddbf635ff20 100644 --- a/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx +++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.view.test.tsx @@ -513,7 +513,9 @@ describe('UserButtonView, the workspace list', () => { it('says so to a screen reader, since nothing else reports the rows landing', () => { renderList({ organizationsLoading: true }); - expect(screen.getByRole('status')).toHaveTextContent('Loading organizations…'); + // The popup's own region speaks for the action a row is running, so the placeholder is asked + // for by its text: the two report different waits and are both on the page here. + expect(screen.getByText('Loading organizations…').closest('[role="status"]')).toBeInTheDocument(); }); it('leaves the account row above it alone, since it does not wait on the list', () => { @@ -726,6 +728,48 @@ describe('UserButtonView, one action at a time', () => { expect(onSwitchSession).not.toHaveBeenCalled(); expect(row).toHaveFocus(); }); + + // The row keeps focus while it stands down, so nothing about the surface reports the wait on its + // own: the spinner is a picture, and `aria-disabled` says a row cannot be used rather than why. + // Named from the key, so the surface says what is running rather than that something is. + it.each([ + [userButtonBusyKeys.selectOrganization('org_2'), 'Switching to Other Co', {}], + [userButtonBusyKeys.selectOrganization(null), 'Switching to Personal account', {}], + [userButtonBusyKeys.switchSession('sess_2'), 'Switching to bob@example.com', {}], + [userButtonBusyKeys.signOutSession('sess_2'), 'Signing out of bob@example.com', {}], + [userButtonBusyKeys.signOutAll(), 'Signing out of all accounts', {}], + [userButtonBusyKeys.acceptInvitation('inv_1'), 'Joining Gamma', { invitations: [gamma] }], + [userButtonBusyKeys.acceptSuggestion('sug_1'), 'Requesting to join Beta', { suggestions: [beta] }], + ])('names the wait in a live region: %s', (pendingKey, announcement, props) => { + render(surface(pendingKey, props)); + + expect(screen.getByRole('status')).toHaveTextContent(announcement); + }); + + // A region that mounts with its message already in it is not announced, so the popup carries an + // empty one from the moment it opens and the message lands in a region that is already there. + it('carries the region while idle', () => { + render(surface(null)); + + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); + + // Picking a workspace closes the popup behind it, so a region living in the popup would be taken + // off the page while it was still being read out. It belongs to the surface, which stays. + it('carries the region whether or not the popup is open', () => { + render(surface(userButtonBusyKeys.selectOrganization('org_2'), { defaultOpen: false })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('Switching to Other Co'); + }); + + // A key can outlive what it names: an account signs out from its own row, and the row is gone + // before the action lands. The region says nothing rather than announcing a half-filled template. + it('says nothing for an action it cannot name', () => { + render(surface(userButtonBusyKeys.switchSession('sess_9'))); + + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); }); describe('UserButtonTrigger', () => { diff --git a/packages/ui/src/mosaic/user-button/user-button.messages.ts b/packages/ui/src/mosaic/user-button/user-button.messages.ts index 65a883e87b0..0bca53cdba1 100644 --- a/packages/ui/src/mosaic/user-button/user-button.messages.ts +++ b/packages/ui/src/mosaic/user-button/user-button.messages.ts @@ -13,6 +13,14 @@ export const userButtonBase = { popup: { label: 'Account', }, + // What the popup's live region says while an action runs, by the affordance that owns it. + status: { + switching: 'Switching to {name}', + signingOut: 'Signing out of {identifier}', + signingOutAll: 'Signing out of all accounts', + joining: 'Joining {name}', + requesting: 'Requesting to join {name}', + }, workspaces: { personal: 'Personal account', loading: 'Loading organizations…', diff --git a/packages/ui/src/mosaic/user-button/user-button.styles.ts b/packages/ui/src/mosaic/user-button/user-button.styles.ts index fa1f0a1834b..fc366b81da5 100644 --- a/packages/ui/src/mosaic/user-button/user-button.styles.ts +++ b/packages/ui/src/mosaic/user-button/user-button.styles.ts @@ -3,6 +3,17 @@ import * as stylex from '@stylexjs/stylex'; import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../tokens.stylex'; export const styles = stylex.create({ + // Takes the live region out of the layout without taking it out of the accessibility tree, which + // `display: none` and `visibility: hidden` both do. + visuallyHidden: { + overflow: 'hidden', + clipPath: 'inset(50%)', + position: 'absolute', + whiteSpace: 'nowrap', + height: '1px', + width: '1px', + }, + // The avatar is the trigger, so the button paints nothing of its own. trigger: { padding: 0, diff --git a/packages/ui/src/mosaic/user-button/user-button.view.tsx b/packages/ui/src/mosaic/user-button/user-button.view.tsx index 860293f722a..0918693a003 100644 --- a/packages/ui/src/mosaic/user-button/user-button.view.tsx +++ b/packages/ui/src/mosaic/user-button/user-button.view.tsx @@ -85,6 +85,48 @@ function useBusy(key?: string): { busy: boolean; disabled: boolean } { return { busy: pendingKey === key, disabled: pendingKey !== key }; } +/** + * What the surface says while an action runs, by the key that owns it. Keyed through + * `userButtonBusyKeys` rather than by parsing `pendingKey`, so the key grammar stays in one place, + * and built from the rows' own names so the announcement names the same thing the row does. + */ +function pendingAnnouncements(data: UserButtonContextValue): Map { + const switching = (name: string) => fill(m.status.switching, { name }); + const announcements = new Map([ + [userButtonBusyKeys.selectOrganization(null), switching(m.workspaces.personal)], + [userButtonBusyKeys.signOutAll(), m.status.signingOutAll], + ]); + + // The active organization is described whole rather than found in `memberships`, so it is named + // here even while the list it belongs to is still loading. + for (const membership of data.activeOrganization + ? [data.activeOrganization, ...data.memberships] + : data.memberships) { + announcements.set(userButtonBusyKeys.selectOrganization(membership.organizationId), switching(membership.name)); + } + // Accounts are named by identifier, the way their rows are. + for (const session of [data.activeSession, ...data.additionalSessions]) { + announcements.set(userButtonBusyKeys.switchSession(session.sessionId), switching(session.identifier)); + announcements.set( + userButtonBusyKeys.signOutSession(session.sessionId), + fill(m.status.signingOut, { identifier: session.identifier }), + ); + } + for (const invitation of data.invitations) { + announcements.set( + userButtonBusyKeys.acceptInvitation(invitation.id), + fill(m.status.joining, { name: invitation.organizationName }), + ); + } + for (const suggestion of data.suggestions) { + announcements.set( + userButtonBusyKeys.acceptSuggestion(suggestion.id), + fill(m.status.requesting, { name: suggestion.name }), + ); + } + return announcements; +} + interface ActiveWorkspace { name: string; imageUrl?: string; @@ -937,7 +979,10 @@ export function UserButtonRoot(props: UserButtonRootProps): ReactElement { placement={placement ?? 'bottom-start'} sideOffset={sideOffset} > - {children} + + {children} + + ); } @@ -989,6 +1034,28 @@ export function UserButtonTrigger({ ); } +/** + * Speaks the one in-flight action. `pendingKey` allows a single action at a time, so the surface + * needs one region rather than one per affordance. + * + * It belongs to the surface rather than to the popup or the acting row, both of which go while the + * action is still running: picking a workspace closes the popup behind it, and a row leaves when the + * list re-sorts or an account signs out. A region taken off the page mid-announcement is not read. + */ +function ActionStatus(): ReactElement { + const data = useUserButtonContext(); + // Mounted whether or not anything is running: a region that arrives with its message already in + // it is not announced, so the message has to land in a region that is already on the page. + return ( + + {(data.pendingKey && pendingAnnouncements(data).get(data.pendingKey)) || ''} + + ); +} + /** The popover surface: header, workspace list, additional accounts, and footer. */ export function UserButtonPopup(): ReactElement { return (