Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ad64a1f
Offer the workspace members bulk actions in the floating bar
Krishna2323 Sep 28, 2026
c963cc9
Drop focus before Esc clears a selection, so no focus ring appears
Krishna2323 Sep 28, 2026
11fd6a4
fix knip check.
Krishna2323 Sep 28, 2026
b3b795a
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Sep 29, 2026
db1fb97
Make the bulk action bar keyboard and screen reader accessible
Krishna2323 Sep 29, 2026
b6d297b
Let Tab move through the bulk action bar's actions
Krishna2323 Sep 29, 2026
eb20697
remove translations.
Krishna2323 Sep 29, 2026
98742eb
Drop the unused BulkActionBarFocus export
Krishna2323 Sep 29, 2026
59fa50d
Keep Enter on a bar button from also opening the focused row
Krishna2323 Sep 29, 2026
0d76bfb
Hand focus back whenever an action takes the bulk action bar away
Krishna2323 Sep 29, 2026
74a1517
Merge branch 'main' into krishna2323/issue/101898-members-bulk-action…
Krishna2323 Oct 1, 2026
df938d8
Consolidate member bulk role actions into a Change role selector
Krishna2323 Oct 1, 2026
695f332
Keep Save pressable on the bulk role selector and ask for a role instead
Krishna2323 Oct 1, 2026
da8dd4b
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 5, 2026
7ad0d18
Use the user pencil icon for the bulk Change role action
Krishna2323 Oct 5, 2026
b847c52
compress svg
Krishna2323 Oct 5, 2026
8bd44a8
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 5, 2026
869c111
update translations.
Krishna2323 Oct 5, 2026
f2309e2
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 8, 2026
16e9368
Register the members role screen with navigation
Krishna2323 Oct 8, 2026
6b132c7
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 8, 2026
b06f43c
Scope the stashed role-change selection to its workspace
Krishna2323 Oct 8, 2026
b7a71f5
Hand focus back when the bulk action bar goes away from another screen
Krishna2323 Oct 8, 2026
c67ba73
Make the bulk action bar and the role error answer the keyboard
Krishna2323 Oct 8, 2026
ed99ae6
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 8, 2026
74205d9
Keep the bulk action bar's hand-back target on the table
Krishna2323 Oct 9, 2026
669a14c
Restore focus to the table after a bulk action navigates
Krishna2323 Oct 9, 2026
7c1925c
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 9, 2026
e2f1301
Point the removed-approver test at the bulk action bar
Krishna2323 Oct 9, 2026
af23a5e
Merge branch 'Expensify:main' into krishna2323/issue/101898-members-b…
Krishna2323 Oct 13, 2026
149c56a
Find the bar's own popover through its launcher rather than through m…
Krishna2323 Oct 13, 2026
579091c
Merge branch 'Expensify:main' into krishna2323/issue/101898-members-b…
Krishna2323 Oct 14, 2026
1237383
Merge branch 'main' into krishna2323/issue/101898-members-bulk-action…
Krishna2323 Oct 15, 2026
21f3e43
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 16, 2026
5e43fb3
Merge branch 'Expensify:main' into krishna2323/issue/101898-members-b…
Krishna2323 Oct 18, 2026
d965210
Merge remote-tracking branch 'upstream/main' into krishna2323/issue/1…
Krishna2323 Oct 20, 2026
eb14294
Remember the control that made the selection
Krishna2323 Oct 20, 2026
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
1 change: 1 addition & 0 deletions assets/images/user-pencil.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 1 addition & 5 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4832,11 +4832,7 @@ const CONST = {
REMOVE: 'remove',
MAKE_MEMBER: 'makeMember',
MAKE_ADMIN: 'makeAdmin',
MAKE_AUDITOR: 'makeAuditor',
MAKE_GUEST: 'makeGuest',
MAKE_CARD_ADMIN: 'makeCardAdmin',
MAKE_PEOPLE_ADMIN: 'makePeopleAdmin',
MAKE_PAYMENTS_ADMIN: 'makePaymentsAdmin',
CHANGE_ROLE: 'changeRole',
},
BULK_ACTION_TYPES: {
DELETE: 'delete',
Expand Down
1 change: 1 addition & 0 deletions src/CONST/runtimeConfigured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const CONST_RUNTIME: ConstRuntime = {
SCREENS.SUBMIT_PLAN_WELCOME_MODAL.DYNAMIC_ROOT,
SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN,
SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
SCREENS.WORKSPACE.MEMBERS_ROLE,
SCREENS.PRE_MOUNT_BUFFER,
...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
],
Expand Down
1 change: 1 addition & 0 deletions src/CONST/runtimeDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const CONST_RUNTIME_DEFAULTS: ConstRuntime = {
'Dynamic_SubmitPlanWelcomeModal_Root',
'Money_Request_Step_Scan',
'Members_Move_To_Group',
'Workspace_Members_Role',
'PreMountBuffer',
'Multifactor_Authentication_Validate_Code',
'Multifactor_Authentication_Outcome_Success',
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,9 @@ const ONYXKEYS = {
/** Selected domain member account IDs for the move-to-group operation */
RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE: 'domainMembersSelectedForMove',

/** The workspace members selected for the bulk role change, with the workspace they were selected on */
RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE: 'workspaceMembersSelectedForRoleChange',

// The theme setting set by the user in preferences.
// This can be either "light", "dark", "system", "light-contrast", "dark-contrast" or "system-contrast"
PREFERRED_THEME: 'nvp_preferredTheme',
Expand Down Expand Up @@ -1878,6 +1881,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.VIEWING_PUBLIC_ROOM_REPORT_ID]: string;
[ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record<string, OnyxTypes.DomainSecurityGroupMembership>;
[ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[];
[ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE]: OnyxTypes.WorkspaceMembersSelectedForRoleChange;
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string;
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE]: string;
[ONYXKEYS.PREFERRED_THEME]: ValueOf<typeof CONST.THEME>;
Expand Down
4 changes: 4 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3864,6 +3864,10 @@ const ROUTES = {
route: 'workspaces/:policyID/taxes/settings/tax-name',
getRoute: (policyID: string) => `workspaces/${policyID}/taxes/settings/tax-name` as const,
},
WORKSPACE_MEMBERS_ROLE: {
route: 'workspaces/:policyID/change-member-roles',
getRoute: (policyID: string) => `workspaces/${policyID}/change-member-roles` as const,
},
WORKSPACE_MEMBER_DETAILS: {
route: 'workspaces/:policyID/members/:accountID',
getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}` as const,
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,7 @@ const SCREENS = {
MORE_FEATURES: 'Workspace_More_Features',
MEMBER_DETAILS: 'Workspace_Member_Details',
MEMBER_DETAILS_ROLE: 'Workspace_Member_Details_Role',
MEMBERS_ROLE: 'Workspace_Members_Role',
MEMBER_WORK_ARRANGEMENT: 'Workspace_Member_Work_Arrangement',
MEMBER_CUSTOM_FIELD: 'Workspace_Member_Custom_Field',
MEMBER_NEW_CARD: 'Workspace_Member_NewCard',
Expand Down
2 changes: 2 additions & 0 deletions src/components/BulkActionBar/BulkActionBarButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ function BulkActionBarButton<TValueType>({option, onSubItemSelected}: BulkAction
size={CONST.BUTTON_SIZE.SMALL}
isDisabled={option.disabled}
accessibilityLabel={option.text}
accessibilityState={hasSubMenu ? {expanded: isMenuVisible} : undefined}
Comment thread
Krishna2323 marked this conversation as resolved.
accessibilityHasPopup={hasSubMenu ? CONST.ROLE.MENU : undefined}
sentryLabel={option.sentryLabel}
>
{!!option.icon && (
Expand Down
57 changes: 46 additions & 11 deletions src/components/BulkActionBar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Text from '@components/Text';
import ThemeProvider from '@components/ThemeProvider';
import ThemeStylesProvider from '@components/ThemeStylesContextProvider';

import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
import useInvertedThemePreference from '@hooks/useInvertedThemePreference';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
Expand All @@ -19,6 +20,8 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import Accessibility from '@libs/Accessibility';
import mergeRefs from '@libs/mergeRefs';
import moveMiniToolbarFocusWithArrowKey from '@libs/moveContextMenuFocusWithArrowKey';
import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView';

import {areAllModalsHidden, setDisableDismissOnEscape} from '@userActions/Modal';
Expand All @@ -39,6 +42,9 @@ import type {BulkActionBarProps} from './types';
import BulkActionBarButton from './BulkActionBarButton';
import BulkActionBarMenuTheme from './BulkActionBarMenuTheme';
import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition';
import useBulkActionBarFocus from './useBulkActionBarFocus';

const noop = () => {};

/**
* The bar's contents. Everything here takes its colors from the theme it is rendered under, which `BulkActionBar`
Expand Down Expand Up @@ -77,6 +83,17 @@ function BulkActionBarContent<TValueType>({
const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'UpArrow']);
const {calculatePopoverPosition} = usePopoverPosition();

const isFocused = useIsFocused();

// Esc clears the selection, but not while something is open over the bar. An RHP the bar is rendered inside
// reports itself visible too, and is told apart by the screen still being focused. The type is not worth reading
// here, because a popover opened and closed over an RHP leaves it stale at `popover`.
const [modal] = useOnyx(ONYXKEYS.MODAL);
const isCoveredByModal = !!modal?.isVisible && !isFocused;

const barElementRef = useRef<ComponentRef<typeof View> | null>(null);
const {suppressStrayFocusRing, isFocusInsideBar} = useBulkActionBarFocus(barElementRef, isCoveredByModal);

const moreAnchorRef = useRef<ComponentRef<typeof View> | null>(null);
const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false);
const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState<AnchorPosition | null>(defaultPopoverAnchorPosition);
Expand All @@ -87,13 +104,6 @@ function BulkActionBarContent<TValueType>({
const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options;
const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : [];

// Esc clears the selection, but not while something is open over the bar. An RHP the bar is rendered inside
// reports itself visible too, and is told apart by the screen still being focused. The type is not worth reading
// here, because a popover opened and closed over an RHP leaves it stale at `popover`.
const [modal] = useOnyx(ONYXKEYS.MODAL);
const isFocused = useIsFocused();
const isCoveredByModal = !!modal?.isVisible && !isFocused;

const canOwnEscape = !modal?.willAlertModalBecomeVisible && !isCoveredByModal;

// The Side Panel is not a `BaseModal`, so it is absent from both the modal state and the registry of open modals.
Expand All @@ -104,18 +114,25 @@ function BulkActionBarContent<TValueType>({
// `willAlertModalBecomeVisible` is a single flag that every modal writes, so closing the topmost of a stack reads
// as though nothing is open any more. The registry of open modals is the only answer that survives stacking, and
// it has to be read when the key arrives rather than at subscribe time.
// Esc inside a text field is how you leave the field, so leave the selection alone there.
useKeyboardShortcut(
CONST.KEYBOARD_SHORTCUTS.ESCAPE,
() => {
if (!areAllModalsHidden()) {
return;
}

suppressStrayFocusRing();
onClearSelection();
},
{isActive: shouldClearSelectionOnEscape},
{isActive: shouldClearSelectionOnEscape, captureOnInputs: false},
);

// The lists this bar floats over run a global Enter shortcut off a keyboard cursor that outlives tabbing away from
// the rows. While one of the bar's own buttons is focused, claim Enter without bubbling so pressing it only works
// that button instead of also opening whichever row the cursor was left on. Same guard as SearchPageFooter's.
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, noop, {isActive: isFocusInsideBar, shouldBubble: false, shouldPreventDefault: false});

// Escape must not dismiss the screen the selection sits on while the bar is the one answering it. The Side Panel
// is deliberately left out of the condition, because each write to this flag resubscribes the app's own Escape
// handler to the top of the stack, over the Side Panel's, and that one Escape would then both dismiss the screen
Expand Down Expand Up @@ -152,16 +169,32 @@ function BulkActionBarContent<TValueType>({
};
}, [isMoreMenuVisible, calculatePopoverPosition]);

// The bar appears where nothing was before, without taking focus, so a screen reader only learns about it and about
// the size of the selection it describes from an announcement.
useAccessibilityAnnouncement(countLabel, !isSelectedCountLoading, {shouldAnnounceOnWeb: true, shouldAnnounceOnNative: true, politeness: 'polite'});

return (
<View
ref={barRef}
ref={mergeRefs(barRef, barElementRef)}
style={styles.bulkActionBar}
role={CONST.ROLE.TOOLBAR}
Comment thread
Krishna2323 marked this conversation as resolved.
accessibilityLabel={translate('bulkActionBar.label')}
// The toolbar role has screen readers promise Left and Right move between the buttons, so the bar has to
// answer them. Same handler as the report action mini toolbar, which is the same row of role=button children.
onKeyDown={moveMiniToolbarFocusWithArrowKey}
onLayout={(event) => onBarLayout(event.nativeEvent.layout.width)}
>
{/* Sized for a three-digit count so the bar keeps still as the selection grows, and so swapping the
spinner for the count does not resize it either. */}
<View style={styles.bulkActionBarCount}>
{isSelectedCountLoading ? <ActivityIndicator color={theme.spinner} /> : <Text style={[styles.textLabel, styles.textStrong, styles.textAlignCenter]}>{countLabel}</Text>}
{isSelectedCountLoading ? (
<ActivityIndicator
color={theme.spinner}
accessibilityLabel={translate('bulkActionBar.loadingSelection')}
/>
) : (
<Text style={[styles.textLabel, styles.textStrong, styles.textAlignCenter]}>{countLabel}</Text>
)}
</View>
{!!noticeText && <Text style={[styles.textLabel, styles.colorMuted]}>{noticeText}</Text>}
{inlineOptions.map((option) => (
Expand All @@ -178,6 +211,8 @@ function BulkActionBarContent<TValueType>({
size={CONST.BUTTON_SIZE.SMALL}
onPress={() => setIsMoreMenuVisible((isVisible) => !isVisible)}
accessibilityLabel={translate('common.more')}
accessibilityState={{expanded: isMoreMenuVisible}}
accessibilityHasPopup={CONST.ROLE.MENU}
sentryLabel={CONST.SENTRY_LABEL.BULK_ACTION_BAR.MORE}
>
<Button.Text>{translate('common.more')}</Button.Text>
Expand Down Expand Up @@ -212,7 +247,7 @@ function BulkActionBarContent<TValueType>({
)}
<PressableWithFeedback
onPress={onClearSelection}
accessibilityLabel={translate('common.close')}
accessibilityLabel={translate('bulkActionBar.clearSelection')}
role={CONST.ROLE.BUTTON}
style={styles.bulkActionBarCloseButton}
sentryLabel={CONST.SENTRY_LABEL.BULK_ACTION_BAR.CLEAR_SELECTION}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type UseBulkActionBarFocus from './types';

const noop = () => {};

/** Native has no tab order and no focus ring, so the bar's controls are reached through the screen reader instead. */
const useBulkActionBarFocus: UseBulkActionBarFocus = () => ({suppressStrayFocusRing: noop, isFocusInsideBar: false});

export default useBulkActionBarFocus;
156 changes: 156 additions & 0 deletions src/components/BulkActionBar/useBulkActionBarFocus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import isHTMLElement from '@libs/isHTMLElement';
import {pickLauncher} from '@libs/LauncherStack';
import {nominateTriggerFallback} from '@libs/NavigationFocusReturn';
import markProgrammaticFocus from '@libs/programmaticFocus';
import restoreFocusWithModality from '@libs/restoreFocusWithModality';

import {useEffect, useLayoutEffect, useRef, useState} from 'react';

import type UseBulkActionBarFocus from './types';

/**
* Whether the browser is already drawing a focus ring on this element. Browsers that cannot be asked are assumed to be
* drawing one, so a ring is never taken away from someone who navigated there with the keyboard.
*/
function isShowingFocusRing(element: HTMLElement): boolean {
try {
return element.matches(':focus-visible');
} catch {
return true;
Comment thread
Krishna2323 marked this conversation as resolved.
}
}

/** Tracks where focus is relative to the bar, and hands it back when the bar goes away holding it. */
const useBulkActionBarFocus: UseBulkActionBarFocus = (barRef, isCoveredByScreen) => {
// Where focus was before the bar took it. The bar goes away with the selection, so focus has to be handed back.
const lastFocusedOutsideRef = useRef<HTMLElement | null>(null);
Comment thread
Krishna2323 marked this conversation as resolved.

const [isFocusInsideBar, setIsFocusInsideBar] = useState(false);

useEffect(() => {
const remember = (target: EventTarget | null) => {
if (!isHTMLElement(target) || target === document.body) {
return;
}

const bar = barRef.current;
if (isHTMLElement(bar) && bar.contains(target)) {
return;
}

// The role screen and the bar's own "More" menu both take focus to controls that leave with them, and the
// menu renders in a popover outside the bar rather than within it. Remembering either would leave the bar
// holding a target that is already gone when it needs one. A popover is identified by the launcher it
// registered as it opened, which it does before it moves focus into itself.
if (isCoveredByScreen || pickLauncher()) {
return;
}

lastFocusedOutsideRef.current = target;
// An action that opens a screen is navigated back from, and the restore that follows aims at the bar
// button it was opened from, which the cleared selection has taken away by then. This is the survivor to
// aim at instead, and it is offered here rather than on unmount because the capture happens first.
nominateTriggerFallback(target);
};

// The bar appears because of a selection, so the control that made it already holds focus and fires no
// focusin for the listener below to see.
if (!lastFocusedOutsideRef.current) {
remember(document.activeElement);
}

const handleFocusIn = (event: FocusEvent) => {
const target = event.target;
if (!isHTMLElement(target)) {
return;
}

const bar = barRef.current;
if (isHTMLElement(bar) && bar.contains(target)) {
setIsFocusInsideBar(true);
return;
}

setIsFocusInsideBar(false);
remember(target);
};

// Focus can also leave for nothing at all, such as an element being removed, which fires no matching focusin.
const handleFocusOut = (event: FocusEvent) => {
if (event.relatedTarget) {
return;
}

setIsFocusInsideBar(false);
};

document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);

return () => {
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
};
}, [barRef, isCoveredByScreen]);

// Most of the bar's actions clear the selection themselves, so the bar is torn out from under the button that was
// just pressed and focus would fall to the document. Handing it back here covers every one of those actions,
// rather than only the two the bar runs itself. A layout cleanup rather than a passive one, because this has to
// read the focus before the bar leaves the DOM.
useLayoutEffect(
() => () => {
nominateTriggerFallback(null);

const bar = barRef.current;
const active = document.activeElement;

// An action run from a screen opened over the table clears the selection from there, so the bar is gone
// before that screen hands its own focus back and there is nothing left for it to hand back to. Focus has
// already fallen to the document by then, which is the same loss as the bar being torn out from under it.
const hasFocusFallenToDocument = !active || active === document.body;

// An action run from the bar's own "More" menu is pressed with focus inside a popover that renders outside
// the bar, so the bar does not contain the focus it nonetheless has to hand back. The popover that leaves
// with the bar is the one whose launcher the bar does contain.
const launcher = pickLauncher();
const isFocusInsideBarPopover = isHTMLElement(bar) && !!launcher && bar.contains(launcher);
if (!hasFocusFallenToDocument && !isFocusInsideBarPopover && !(isHTMLElement(bar) && bar.contains(active))) {
return;
}

const previous = lastFocusedOutsideRef.current;
if (!previous?.isConnected) {
return;
}

// The screen that cleared the selection may still be unwinding its focus trap, which would otherwise pull
// focus straight back into the container it is closing.
restoreFocusWithModality(previous);
},
[barRef],
);

const suppressStrayFocusRing = () => {
const active = document.activeElement;
if (!isHTMLElement(active)) {
return;
}

const bar = barRef.current;
if (isHTMLElement(bar) && bar.contains(active)) {
return;
}

// Esc makes whatever holds focus match `:focus-visible`, so a checkbox clicked with the mouse would light up
// with a focus ring just as the selection it belongs to disappears.
if (isShowingFocusRing(active)) {
return;
}

markProgrammaticFocus(active);
};

return {suppressStrayFocusRing, isFocusInsideBar};
};

export default useBulkActionBarFocus;
Loading
Loading