Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-user-button-pending-announcement.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

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:

#!/bin/bashset -euo pipefail
package_json="$(fd --type f --max-depth 1 '^package\.json$' packages/ui | head -n1)"test -n "$package_json"
jq '{name, private}'"$package_json"

Repository: clerk/javascript

Length of output: 199


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- packages/ui/package.json ---'
cat packages/ui/package.json
printf'%s\n''--- changeset configuration ---'
fd --type f --max-depth 2 '^(config\.json|package\.json)$' .changeset packages | sort | head -40
iftest -f .changeset/config.json;then
cat .changeset/config.json
fiprintf'%s\n''--- workspace package metadata ---'
rg -n '"`@clerk/ui`"|packages/ui|private|publishConfig' package.json pnpm-workspace.yaml turbo.json .changeset packages --glob 'package.json' --glob 'pnpm-workspace.yaml' --glob 'turbo.json' --glob '!**/node_modules/**'| head -160

Repository: clerk/javascript

Length of output: 9775


🏁 Script executed:

#!/bin/bashset -euo pipefail
printf'%s\n''--- target changeset ---'
cat .changeset/mosaic-user-button-pending-announcement.md
printf'%s\n''--- UserButtonBusyState definitions and references ---'
rg -n -C 3 'UserButtonBusyState' packages
printf'%s\n''--- current change summary ---'
git diff --stat
git diff --unified=3 -- packages/ui packages/react packages/clerk-js .changeset/mosaic-user-button-pending-announcement.md | head -240

Repository: clerk/javascript

Length of output: 2744


Add a Changeset entry for @clerk/ui

@clerk/ui is publicly published. Add the appropriate version bump and release note for the exported UserButtonBusyState change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.changeset/mosaic-user-button-pending-announcement.md around lines 1 - 2,
Add a Changeset entry for the publicly published `@clerk/ui` package, specifying
the appropriate version bump and a release note describing the exported
UserButtonBusyState change.

Sources: Coding guidelines, Learnings

Original file line numberDiff line numberDiff line change
Expand Up@@ -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', () => {
Expand DownExpand Up@@ -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', () => {
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.messages.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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…',
Expand Down
11 changes: 11 additions & 0 deletions packages/ui/src/mosaic/user-button/user-button.styles.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The 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,
Expand Down
69 changes: 68 additions & 1 deletion packages/ui/src/mosaic/user-button/user-button.view.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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>
);
}
Expand DownExpand Up@@ -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>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

/** The popover surface: header, workspace list, additional accounts, and footer. */
export function UserButtonPopup(): ReactElement {
return (
Expand Down
Loading