Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 469
feat(ui): announce the user button's in-flight action#9404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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', | ||
| }, | ||
Comment on lines
+8
to
+15
MemberAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. probably should lift to an atom in the future to reuse. | ||
| // The avatar is the trigger, so the button paints nothing of its own. | ||
| trigger: { | ||
| padding: 0, | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, string> { | ||
| const switching = (name: string) => fill(m.status.switching, { name }); | ||
| const announcements = new Map<string, string>([ | ||
| [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} | ||
| > | ||
| <UserButtonContext.Provider value={{ ...data, layout }}>{children}</UserButtonContext.Provider> | ||
| <UserButtonContext.Provider value={{ ...data, layout }}> | ||
| {children} | ||
| <ActionStatus /> | ||
| </UserButtonContext.Provider> | ||
| </Popover.Root> | ||
| ); | ||
| } | ||
| @@ -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 ( | ||
| <span | ||
| role='status' | ||
| {...stylex.props(styles.visuallyHidden)} | ||
| > | ||
| {(data.pendingKey && pendingAnnouncements(data).get(data.pendingKey)) || ''} | ||
| </span> | ||
coderabbitai[bot] marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| ); | ||
| } | ||
| /** The popover surface: header, workspace list, additional accounts, and footer. */ | ||
| export function UserButtonPopup(): ReactElement { | ||
| return ( | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: clerk/javascript
Length of output: 199
🏁 Script executed:
Repository: clerk/javascript
Length of output: 9775
🏁 Script executed:
Repository: clerk/javascript
Length of output: 2744
Add a Changeset entry for
@clerk/ui@clerk/uiis publicly published. Add the appropriate version bump and release note for the exportedUserButtonBusyStatechange.🤖 Prompt for AI Agents
Sources: Coding guidelines, Learnings