-
+
UUID: {event.EventUuid} - Replay Id: {event.replayId}
diff --git a/libs/features/platform-event-monitor/src/PlatformEventMonitorEvents.tsx b/libs/features/platform-event-monitor/src/PlatformEventMonitorEvents.tsx
index 436d9bf01..487b8b052 100644
--- a/libs/features/platform-event-monitor/src/PlatformEventMonitorEvents.tsx
+++ b/libs/features/platform-event-monitor/src/PlatformEventMonitorEvents.tsx
@@ -1,10 +1,10 @@
import { css } from '@emotion/react';
import { logger } from '@jetstream/shared/client-logger';
-import { setItemInLocalStorage } from '@jetstream/shared/ui-utils';
-import { orderValues } from '@jetstream/shared/utils';
+import { formatNumber, setItemInLocalStorage } from '@jetstream/shared/ui-utils';
+import { orderValues, pluralizeIfMultiple } from '@jetstream/shared/utils';
import { ContextMenuItem } from '@jetstream/types';
import type { RenderCellProps, RowHeightArgs, SortColumn } from '@jetstream/ui';
-import { AutoFullHeightContainer, ColumnWithFilter, ContextMenuActionData, DataTree } from '@jetstream/ui';
+import { AssistiveStatus, AutoFullHeightContainer, ColumnWithFilter, ContextMenuActionData, DataTree } from '@jetstream/ui';
import { STORAGE_KEYS } from '@jetstream/ui/app-state';
import copyToClipboard from 'copy-to-clipboard';
import groupBy from 'lodash/groupBy';
@@ -70,6 +70,9 @@ const columns: ColumnWithFilter[] = [
const groupedRows = ['event'] as const;
+/** Long enough that a burst of events announces once instead of per message */
+const EVENT_COUNT_ANNOUNCE_DEBOUNCE_MS = 1000;
+
function getRowId(data: PlatformEventRow): string {
return data.uuid || `${data.replayId}` || JSON.stringify(data);
}
@@ -151,6 +154,12 @@ export const PlatformEventMonitorEvents: FunctionComponent
+ {/* Events stream in continuously; announcing each one would be unusable, so announce the
+ running total once a burst settles. The grid itself carries the row count via aria-rowcount. */}
+
- fetchPlatformEvents(true)}>
- Just added a new event?
-
+
+ fetchPlatformEvents(true)}
+ >
+ Just added a new event?
+
+
onClearAll()}
>
-
+
@@ -70,7 +82,13 @@ export const PlatformEventMonitorListenerCard = ({
disabled={!hasSubscriptions}
onClick={() => onClearEvents()}
>
-
+
onDownload()}>
@@ -93,16 +111,23 @@ export const PlatformEventMonitorListenerCard = ({
onSelectedSubscribeEvent={onSelectedSubscribeEvent}
/>
- {subscribedPlatformEventsList.map((item) => (
-
unsubscribe(item.value)}
- >
- {item.label}
-
- ))}
+ {/* Not a listbox: these are active subscriptions with a remove action, not selectable options.
+ Plain pills keep the unsubscribe button as the single, named tab stop for each one. */}
+ {subscribedPlatformEventsList.length > 0 && (
+
+ {subscribedPlatformEventsList.map((item) => (
+
+ unsubscribe(item.value)}
+ >
+ {item.label}
+
+
+ ))}
+
+ )}
diff --git a/libs/features/platform-event-monitor/src/PlatformEventMonitorPublisherCard.tsx b/libs/features/platform-event-monitor/src/PlatformEventMonitorPublisherCard.tsx
index d70b36f7c..1b5a33bfd 100644
--- a/libs/features/platform-event-monitor/src/PlatformEventMonitorPublisherCard.tsx
+++ b/libs/features/platform-event-monitor/src/PlatformEventMonitorPublisherCard.tsx
@@ -3,11 +3,14 @@ import { clearCacheForOrg, describeSObject } from '@jetstream/shared/data';
import { useReducerFetchFn } from '@jetstream/shared/ui-utils';
import { getErrorMessage } from '@jetstream/shared/utils';
import { DescribeSObjectResult, ListItem, Maybe, PicklistFieldValues, SalesforceOrgUi, SalesforceRecord } from '@jetstream/types';
-import { Card, ComboboxWithItems, Grid, Icon, ScopedNotification, Spinner, Tooltip } from '@jetstream/ui';
+import { AssistiveStatus, Card, ComboboxWithItems, Grid, Icon, ScopedNotification, Spinner, Tooltip } from '@jetstream/ui';
import { formatRelative } from 'date-fns/formatRelative';
import { Fragment, FunctionComponent, useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { PlatformEventObject } from './platform-event-monitor.types';
+/** The submit button renders in the Card's action slot, outside the form, so it associates by id */
+const PUBLISH_FORM_ID = 'publish-platform-event-form';
+
export interface PlatformEventMonitorPublisherCardProps {
selectedOrg: SalesforceOrgUi;
serverUrl: string;
@@ -127,6 +130,14 @@ export const PlatformEventMonitorPublisherCard: FunctionComponent
) {
+ ev.preventDefault();
+ if (!sobjectDescribeLoaded || !sobjectDescribeData || publishLoading) {
+ return;
+ }
+ publishEvent(publishEventRecord);
+ }
+
function handlePlatformEventChange(item: ListItem) {
onSelectedPublishEvent(item.id);
clearForm();
@@ -137,6 +148,15 @@ export const PlatformEventMonitorPublisherCard: FunctionComponent key + 1);
}
+ // The publish outcome renders as a static notification, which screen readers do not announce
+ const publishStatusMessage = publishLoading
+ ? 'Publishing event'
+ : publishEventResponse
+ ? publishEventResponse.success
+ ? `Event published. Event Id: ${publishEventResponse.eventId}`
+ : `There was an error publishing your event: ${publishEventResponse.errorMessage}`
+ : '';
+
return (
publishEvent(publishEventRecord)}
>
Publish Event
{publishLoading && }
@@ -155,82 +176,92 @@ export const PlatformEventMonitorPublisherCard: FunctionComponent
{(loadingPlatformEvents || sobjectDescribeLoading) && }
-
-
-
-
+
+
);
};
diff --git a/libs/features/platform-event-monitor/src/PlatformEventMonitorSubscribe.tsx b/libs/features/platform-event-monitor/src/PlatformEventMonitorSubscribe.tsx
index 2f94944cc..3fc36eb45 100644
--- a/libs/features/platform-event-monitor/src/PlatformEventMonitorSubscribe.tsx
+++ b/libs/features/platform-event-monitor/src/PlatformEventMonitorSubscribe.tsx
@@ -1,8 +1,8 @@
import { css } from '@emotion/react';
import { isEnterKey } from '@jetstream/shared/ui-utils';
import { ListItemGroup, Maybe } from '@jetstream/types';
-import { ComboboxWithGroupedItems, Grid, Input } from '@jetstream/ui';
-import React, { FunctionComponent, KeyboardEvent, useEffect, useState } from 'react';
+import { AssistiveStatus, ComboboxWithGroupedItems, Grid, Input } from '@jetstream/ui';
+import React, { FunctionComponent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { PlatformEventObject } from './platform-event-monitor.types';
import { MessagesByChannel } from './usePlatformEvent';
@@ -29,9 +29,19 @@ export const PlatformEventMonitorSubscribe: FunctionComponent
{
const [replayId, setReplayId] = useState('-1');
const [currentEventSubscribed, setCurrentEventSubscribed] = useState(false);
+ const [subscriptionStatusMessage, setSubscriptionStatusMessage] = useState('');
+ const previousSubscriptionRef = useRef<{ channel: Maybe; subscribed: boolean } | null>(null);
+ // Subscribe / Unsubscribe only swap the button label, which is not announced — announce the outcome
+ // when the selected event's subscription state flips (selecting a different event is not an outcome)
useEffect(() => {
- setCurrentEventSubscribed(!!selectedSubscribeEvent && !!messagesByChannel[selectedSubscribeEvent]);
+ const subscribed = !!selectedSubscribeEvent && !!messagesByChannel[selectedSubscribeEvent];
+ setCurrentEventSubscribed(subscribed);
+ const previous = previousSubscriptionRef.current;
+ if (previous && selectedSubscribeEvent && previous.channel === selectedSubscribeEvent && previous.subscribed !== subscribed) {
+ setSubscriptionStatusMessage(subscribed ? `Subscribed to ${selectedSubscribeEvent}` : `Unsubscribed from ${selectedSubscribeEvent}`);
+ }
+ previousSubscriptionRef.current = { channel: selectedSubscribeEvent, subscribed };
}, [selectedSubscribeEvent, messagesByChannel]);
useEffect(() => {
@@ -104,20 +114,22 @@ export const PlatformEventMonitorSubscribe: FunctionComponent
- {currentEventSubscribed && (
- selectedSubscribeEvent && unsubscribe(selectedSubscribeEvent)}
- disabled={!selectedSubscribeEvent}
- >
- Unsubscribe
-
- )}
- {!currentEventSubscribed && (
-
- Subscribe
-
- )}
+ {/* One stable element for both states — swapping two buttons dropped keyboard focus to
+ the moment the subscription state flipped */}
+ {
+ if (currentEventSubscribed) {
+ selectedSubscribeEvent && unsubscribe(selectedSubscribeEvent);
+ } else {
+ handleSubscribe(event);
+ }
+ }}
+ disabled={!selectedSubscribeEvent}
+ >
+ {currentEventSubscribed ? 'Unsubscribe' : 'Subscribe'}
+
+
);
diff --git a/libs/features/query/src/QueryBuilder/ExecuteQueryButton.tsx b/libs/features/query/src/QueryBuilder/ExecuteQueryButton.tsx
index 6f911e55a..4396bc1a2 100644
--- a/libs/features/query/src/QueryBuilder/ExecuteQueryButton.tsx
+++ b/libs/features/query/src/QueryBuilder/ExecuteQueryButton.tsx
@@ -1,5 +1,5 @@
import { DescribeGlobalSObjectResult, Maybe, SalesforceOrgUi } from '@jetstream/types';
-import { Icon, KeyboardShortcut, Tooltip, getModifierKey } from '@jetstream/ui';
+import { getAriaKeyshortcuts, getModifierKey, Icon, KeyboardShortcut, Tooltip } from '@jetstream/ui';
import { recentHistoryItemsDb } from '@jetstream/ui/db';
import { FunctionComponent } from 'react';
import { Link } from 'react-router';
@@ -41,6 +41,7 @@ export const ExecuteQueryButton: FunctionComponent = ({
name: selectedSObject.name,
},
}}
+ aria-keyshortcuts={getAriaKeyshortcuts([getModifierKey(), 'enter'])}
data-testid="execute-query-button"
onClick={handleClick}
>
diff --git a/libs/features/query/src/QueryBuilder/QuerySubqueryConfigPanel.tsx b/libs/features/query/src/QueryBuilder/QuerySubqueryConfigPanel.tsx
index e88cda746..ae6eab9e6 100644
--- a/libs/features/query/src/QueryBuilder/QuerySubqueryConfigPanel.tsx
+++ b/libs/features/query/src/QueryBuilder/QuerySubqueryConfigPanel.tsx
@@ -3,7 +3,7 @@ import { logger } from '@jetstream/shared/client-logger';
import { fetchFields, getListItemsFromFieldWithRelatedItems, sortQueryFields, unFlattenedListItemsById } from '@jetstream/shared/ui-utils';
import { groupByFlat } from '@jetstream/shared/utils';
import { ExpressionType, Field, ListItem, QueryFields, QueryOrderByClause, SalesforceOrgUi } from '@jetstream/types';
-import { Panel, Spinner } from '@jetstream/ui';
+import { ariaDisabledButtonProps, Panel, Spinner } from '@jetstream/ui';
import { fromQueryState } from '@jetstream/ui-core';
import { getSubqueryFieldBaseKey, removeInFlightQueryFields } from '@jetstream/ui-core/shared';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
@@ -300,12 +300,12 @@ export const QuerySubqueryConfigPanel: FunctionComponent
+ {/* Stays focusable while its own click disables it — native disabled would drop focus to */}
handleClearAll())}
>
Clear
diff --git a/libs/features/query/src/QueryBuilder/QuerySubqueryLevel.tsx b/libs/features/query/src/QueryBuilder/QuerySubqueryLevel.tsx
index f6be8eca5..bb4a9127c 100644
--- a/libs/features/query/src/QueryBuilder/QuerySubqueryLevel.tsx
+++ b/libs/features/query/src/QueryBuilder/QuerySubqueryLevel.tsx
@@ -439,6 +439,9 @@ export const QuerySubqueryLevel: FunctionComponent = ({
initOpenIds={focusedSectionId ? [focusedSectionId] : []}
scrollInitOpenIdIntoView
allowMultiple={false}
+ // An object can have dozens of child relationships — one tab stop with ArrowUp/ArrowDown
+ // between headers keeps the rest of the page reachable by keyboard
+ singleTabStop
sections={visibleChildRelationships.map((childRelationship) => ({
id: getSectionId(currentRelationshipPath, childRelationship),
testId: childRelationship.relationshipName,
diff --git a/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx b/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx
index fd68fff8b..61d16e63b 100644
--- a/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx
+++ b/libs/features/query/src/QueryBuilder/QuerySubquerySObjects.tsx
@@ -6,7 +6,7 @@ import { fromQueryState } from '@jetstream/ui-core';
import { getSubqueryFieldBaseKey } from '@jetstream/ui-core/shared';
import { useAtomValue, useSetAtom } from 'jotai';
import isNumber from 'lodash/isNumber';
-import { Fragment, FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildSelectedSubqueryTree,
countSubqueriesBelow,
@@ -109,11 +109,16 @@ export const QuerySubquerySObjects: FunctionComponent.
*/
+ const containerRef = useRef(null);
function handleClearAll() {
clearSubqueries(currentRelationshipPath);
goToLevels((currentLevels) => currentLevels.slice(0, -1));
+ window.setTimeout(() => {
+ containerRef.current?.querySelector('button, a[href], input, [tabindex="0"]')?.focus();
+ });
}
/**
@@ -159,7 +164,8 @@ export const QuerySubquerySObjects: FunctionComponent
+ // display: contents keeps the wrapper out of the layout; it only scopes the focus fallback below
+
{levels.length > 0 && (
goToLevels((currentLevels) => [...currentLevels, level])}
/>
)}
-
+
);
};
diff --git a/libs/features/query/src/QueryOptions/ManualSoql.tsx b/libs/features/query/src/QueryOptions/ManualSoql.tsx
index ffabdfbdd..49c13cf51 100644
--- a/libs/features/query/src/QueryOptions/ManualSoql.tsx
+++ b/libs/features/query/src/QueryOptions/ManualSoql.tsx
@@ -188,6 +188,8 @@ export const ManualSoql: FunctionComponent
= ({ className, isTo
>
[
...prevItems,
@@ -59,7 +61,15 @@ export const QueryFieldFunction = ({ hasGroupByClause, selectedFields }: QueryFi
]);
}
+ /**
+ * The delete button unmounts with its row, which would drop keyboard focus to — land on
+ * the previous row's delete button (or the row that slides into slot 0, which is always present
+ * because an emptied list is refilled with one blank row)
+ */
function handleDeleteRow(index: number) {
+ window.setTimeout(() => {
+ document.getElementById(`${DELETE_ROW_BUTTON_ID_PREFIX}${Math.max(index - 1, 0)}`)?.focus();
+ });
setFieldFilterFunctions((prevItems) => {
const output = prevItems.filter((_, i) => i !== index);
if (!output.length) {
@@ -89,7 +99,12 @@ export const QueryFieldFunction = ({ hasGroupByClause, selectedFields }: QueryFi
onChange={(selectedField, selectedFunction, alias) => handleChange(i, selectedField, selectedFunction, alias)}
/>
- handleDeleteRow(i)}>
+ handleDeleteRow(i)}
+ >
diff --git a/libs/features/query/src/QueryOptions/QueryFieldFunctionRow.tsx b/libs/features/query/src/QueryOptions/QueryFieldFunctionRow.tsx
index ccf78372a..55393b5d8 100644
--- a/libs/features/query/src/QueryOptions/QueryFieldFunctionRow.tsx
+++ b/libs/features/query/src/QueryOptions/QueryFieldFunctionRow.tsx
@@ -105,6 +105,7 @@ export const QueryFieldFunctionRow = ({
}
buttonProps={{
className: classNames('slds-button slds-button_icon slds-button_icon-border-filled', { 'slds-is-selected': !!alias }),
+ 'aria-label': 'Set field alias',
disabled: !selectedField,
}}
>
diff --git a/libs/features/query/src/QueryOptions/QueryGroupBy.tsx b/libs/features/query/src/QueryOptions/QueryGroupBy.tsx
index 0b36598ba..8fdf843bc 100644
--- a/libs/features/query/src/QueryOptions/QueryGroupBy.tsx
+++ b/libs/features/query/src/QueryOptions/QueryGroupBy.tsx
@@ -1,8 +1,8 @@
import { ListItem, QueryGroupByClause } from '@jetstream/types';
-import { Icon } from '@jetstream/ui';
+import { Icon, ariaDisabledButtonProps } from '@jetstream/ui';
import { fromQueryState } from '@jetstream/ui-core';
import { useAtom } from 'jotai';
-import { Fragment, useState } from 'react';
+import { useRef, useState } from 'react';
import QueryGroupByRow from './QueryGroupByRow';
export interface QueryGroupByContainerProps {
@@ -14,6 +14,8 @@ export interface QueryGroupByContainerProps {
export const QueryGroupByContainer = ({ sobject, fields, onLoadRelatedFields }: QueryGroupByContainerProps) => {
const [groupByClauses, setGroupByClauses] = useAtom(fromQueryState.queryGroupByState);
const [nextKey, setNextKey] = useState(1);
+ // Scoped to this instance so a second mount of the component can never receive the focus
+ const containerRef = useRef(null);
function handleUpdate(groupBy: QueryGroupByClause) {
setGroupByClauses(groupByClauses.map((currItem) => (currItem.key === groupBy.key ? groupBy : currItem)));
@@ -25,6 +27,15 @@ export const QueryGroupByContainer = ({ sobject, fields, onLoadRelatedFields }:
}
function handleDelete(deletedGroupBy: QueryGroupByClause) {
+ // The delete button unmounts with its row, which would drop keyboard focus to — land on
+ // the previous row's delete button (row 0 always exists: an emptied list is refilled with one row)
+ const deletedIndex = groupByClauses.findIndex((groupBy) => groupBy.key === deletedGroupBy.key);
+ window.setTimeout(() => {
+ const deleteButtons = containerRef.current?.querySelectorAll(
+ '[role="group"][aria-label^="Group by row "] button[title="Delete Condition"]',
+ );
+ deleteButtons?.[Math.max(deletedIndex - 1, 0)]?.focus();
+ });
const tempGroupByClauses = groupByClauses.filter((groupBy) => groupBy.key !== deletedGroupBy.key);
// ensure there is always at least one group by
if (tempGroupByClauses.length === 0) {
@@ -35,7 +46,7 @@ export const QueryGroupByContainer = ({ sobject, fields, onLoadRelatedFields }:
}
return (
-
+
{groupByClauses.map((groupBy, i) => (
))}
- = 5}>
+ {/* The click that adds the fifth row disables this button — aria-disabled keeps focus on it */}
+ = 5, handleAdd)}>
Add Group By
-
+
);
};
diff --git a/libs/features/query/src/QueryOptions/QueryGroupByRow.tsx b/libs/features/query/src/QueryOptions/QueryGroupByRow.tsx
index 208b16a9f..d01f78a34 100644
--- a/libs/features/query/src/QueryOptions/QueryGroupByRow.tsx
+++ b/libs/features/query/src/QueryOptions/QueryGroupByRow.tsx
@@ -32,7 +32,7 @@ export const QueryGroupByRow: FunctionComponent = ({
);
return (
-
+
{/* Resource */}
[] = [
export const QueryOrderByContainer: FunctionComponent = React.memo(
({ sobject, fields, orderByClauses, setOrderByClauses, onLoadRelatedFields }) => {
+ // The same component is mounted in the query builder AND the subquery panel, so the focus target
+ // must be looked up inside this instance, not the document
+ const containerRef = useRef(null);
function getNextKey(clauses: QueryOrderByClause[]) {
return clauses.reduce((max, clause) => Math.max(max, clause.key), -1) + 1;
}
@@ -38,6 +41,15 @@ export const QueryOrderByContainer: FunctionComponent — land on
+ // the previous row's delete button (row 0 always exists: an emptied list is refilled with one row)
+ const deletedIndex = orderByClauses.findIndex((orderBy) => orderBy.key === deletedOrderby.key);
+ window.setTimeout(() => {
+ const deleteButtons = containerRef.current?.querySelectorAll(
+ '[role="group"][aria-label^="Order by row "] button[title="Delete Condition"]',
+ );
+ deleteButtons?.[Math.max(deletedIndex - 1, 0)]?.focus();
+ });
const tempOrderByClauses = orderByClauses.filter((orderBy) => orderBy.key !== deletedOrderby.key);
// ensure there is always at least one order by
if (tempOrderByClauses.length === 0) {
@@ -47,7 +59,7 @@ export const QueryOrderByContainer: FunctionComponent
+
{orderByClauses.map((orderBy, i) => (
))}
- = 5}>
+ {/* The click that adds the fifth row disables this button — aria-disabled keeps focus on it */}
+ = 5, handleAdd)}>
Add Order By
-
+
);
},
);
diff --git a/libs/features/query/src/QueryOptions/QueryOrderByRow.tsx b/libs/features/query/src/QueryOptions/QueryOrderByRow.tsx
index 5a5821519..9dada4404 100644
--- a/libs/features/query/src/QueryOptions/QueryOrderByRow.tsx
+++ b/libs/features/query/src/QueryOptions/QueryOrderByRow.tsx
@@ -34,7 +34,7 @@ export const QueryOrderByRow: FunctionComponent = ({
const [initialSelectedNulls] = useState(nulls.find((item) => item.value === orderBy.nulls) || nulls[0]);
return (
-
+
{/* Resource */}
= ({ cla
return (
diff --git a/libs/features/query/src/QueryOptions/SaveFavoriteSoql.tsx b/libs/features/query/src/QueryOptions/SaveFavoriteSoql.tsx
index 4b18fe1fa..09d95945b 100644
--- a/libs/features/query/src/QueryOptions/SaveFavoriteSoql.tsx
+++ b/libs/features/query/src/QueryOptions/SaveFavoriteSoql.tsx
@@ -1,7 +1,7 @@
import { logger } from '@jetstream/shared/client-logger';
import { ANALYTICS_KEYS } from '@jetstream/shared/constants';
import { Maybe, QueryHistoryItem, SalesforceOrgUi } from '@jetstream/types';
-import { Grid, Icon, Input, Popover, PopoverRef, Spinner, Textarea } from '@jetstream/ui';
+import { ariaDisabledButtonProps, Grid, Icon, Input, Popover, PopoverRef, Spinner, Textarea } from '@jetstream/ui';
import { fromQueryHistoryState, MonacoEditor, useAmplitude } from '@jetstream/ui-core';
import { queryHistoryDb } from '@jetstream/ui/db';
import { Fragment, FunctionComponent, useEffect, useRef, useState } from 'react';
@@ -72,7 +72,8 @@ export const SaveFavoriteSoql: FunctionComponent = ({
}
async function handleSave() {
- if (!queryHistoryItem || !sObject || !sObjectLabel) {
+ // Guard for aria-disabled (not browser-enforced) and the form-submit path
+ if (!isDirty || !queryHistoryItem || !sObject || !sObjectLabel) {
return;
}
const newQueryHistoryItem: QueryHistoryItem = { ...queryHistoryItem, customLabel: name.trim(), isFavorite: true };
@@ -190,12 +191,13 @@ export const SaveFavoriteSoql: FunctionComponent = ({
handleRemove()} disabled={isDirty}>
Remove
+ {/* The form's onSubmit owns the save (a click here submits the form) — the guarded click
+ only blocks the submit while disabled, otherwise the save would fire twice per click */}
handleSave()}
type="submit"
+ {...ariaDisabledButtonProps(!isDirty, () => {})}
>
{isDirty ? (
'Save'
diff --git a/libs/features/query/src/QueryOptions/__tests__/QueryOrderBy.spec.tsx b/libs/features/query/src/QueryOptions/__tests__/QueryOrderBy.spec.tsx
new file mode 100644
index 000000000..8d2a1490a
--- /dev/null
+++ b/libs/features/query/src/QueryOptions/__tests__/QueryOrderBy.spec.tsx
@@ -0,0 +1,125 @@
+import { ListItem, QueryOrderByClause } from '@jetstream/types';
+import { fromQueryState } from '@jetstream/ui-core';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { QueryOrderByContainer } from '../QueryOrderBy';
+
+// The real app-state module fetches app info and the profile at module load; the ui-core barrel the
+// component imports `fromQueryState` from reaches it, so stub the atoms it resolves at module load
+vi.mock('@jetstream/ui/app-state', async () => {
+ const { atom } = await import('jotai');
+ return {
+ applicationCookieState: atom({ serverUrl: 'http://localhost' }),
+ selectedOrgState: atom({ uniqueId: 'org-1', label: 'Org 1' }),
+ };
+});
+
+const FIELDS: ListItem[] = [
+ { id: 'Id', label: 'Record ID', value: 'Id' },
+ { id: 'Name', label: 'Account Name', value: 'Name' },
+];
+
+/** Owns the clause list the same way the query builder / subquery panel does */
+function OrderByHarness({ label, initialClauses }: { label: string; initialClauses: QueryOrderByClause[] }) {
+ const [orderByClauses, setOrderByClauses] = useState(initialClauses);
+ return (
+
+ Promise.resolve([])}
+ />
+
+ );
+}
+
+function buildClauses(count: number): QueryOrderByClause[] {
+ return Array.from({ length: count }, (_, i) => fromQueryState.initOrderByClause(i));
+}
+
+/** Two instances side by side, as the app mounts one in the query builder and one in the subquery panel */
+function setup({ parentRowCount = 2, subqueryRowCount = 2 }: { parentRowCount?: number; subqueryRowCount?: number } = {}) {
+ render(
+ <>
+
+
+ >,
+ );
+ return {
+ parent: screen.getByRole('region', { name: 'Query order by' }),
+ subquery: screen.getByRole('region', { name: 'Subquery order by' }),
+ };
+}
+
+function getRows(instance: HTMLElement) {
+ return within(instance).getAllByRole('group', { name: /^Order by row \d+$/ });
+}
+
+function getDeleteButton(instance: HTMLElement, rowNumber: number) {
+ return within(within(instance).getByRole('group', { name: `Order by row ${rowNumber}` })).getByTitle('Delete Condition');
+}
+
+describe('QueryOrderByContainer', () => {
+ it('numbers the rows of each instance independently', () => {
+ const { parent, subquery } = setup();
+
+ expect(getRows(parent)).toHaveLength(2);
+ expect(getRows(subquery)).toHaveLength(2);
+ });
+
+ it('moves focus to the previous row of the SAME instance after a row is deleted', async () => {
+ const { parent, subquery } = setup();
+ const deleteButton = getDeleteButton(subquery, 2);
+ deleteButton.focus();
+
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => expect(getRows(subquery)).toHaveLength(1));
+ // the delete button unmounted with its row, so focus is handed to the previous row's delete button
+ await waitFor(() => expect(document.activeElement).toBe(getDeleteButton(subquery, 1)));
+ expect(parent.contains(document.activeElement)).toBe(false);
+ expect(getRows(parent)).toHaveLength(2);
+ });
+
+ it('keeps focus in the instance when the only row is deleted, since the list refills with one row', async () => {
+ const { parent, subquery } = setup({ subqueryRowCount: 1 });
+ const deleteButton = getDeleteButton(subquery, 1);
+ deleteButton.focus();
+
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => expect(document.activeElement).toBe(getDeleteButton(subquery, 1)));
+ expect(getRows(subquery)).toHaveLength(1);
+ expect(subquery.contains(document.activeElement)).toBe(true);
+ expect(parent.contains(document.activeElement)).toBe(false);
+ });
+
+ it('deleting the first of two rows lands on the row that is now first', async () => {
+ const { subquery } = setup();
+ const deleteButton = getDeleteButton(subquery, 1);
+ deleteButton.focus();
+
+ fireEvent.click(deleteButton);
+
+ await waitFor(() => expect(getRows(subquery)).toHaveLength(1));
+ await waitFor(() => expect(document.activeElement).toBe(getDeleteButton(subquery, 1)));
+ });
+
+ it('adds a row and caps the list at five', () => {
+ const { subquery } = setup({ subqueryRowCount: 4 });
+ const addButton = within(subquery).getByRole('button', { name: 'Add Order By' });
+
+ addButton.focus();
+ fireEvent.click(addButton);
+
+ expect(getRows(subquery)).toHaveLength(5);
+ // The click disables the button — through aria-disabled, so focus stays on it instead of falling to body
+ expect(addButton.getAttribute('aria-disabled')).toBe('true');
+ expect(document.activeElement).toBe(addButton);
+ fireEvent.click(addButton);
+ expect(getRows(subquery)).toHaveLength(5);
+ });
+});
diff --git a/libs/features/query/src/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx b/libs/features/query/src/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx
index 292dc5bcd..c0c9422c2 100644
--- a/libs/features/query/src/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx
+++ b/libs/features/query/src/QueryResults/BulkUpdateFromQuery/BulkUpdateFromQueryModal.tsx
@@ -4,6 +4,7 @@ import { convertDateToLocale, filterLoadSobjects, formatNumber, tracker, useInte
import { getErrorMessage, getRecordIdFromAttributes, pluralizeFromNumber } from '@jetstream/shared/utils';
import { ListItem, Maybe, SalesforceOrgUi, SalesforceRecord } from '@jetstream/types';
import {
+ ariaDisabledButtonProps,
Checkbox,
getWhichRecordsDefaultValue,
Grid,
@@ -380,11 +381,13 @@ export const BulkUpdateFromQueryModal: FunctionComponent
Close
+ {/* Both stay focusable while their own click disables them — native disabled would drop focus to */}
{mode === 'configure' && (
+ handlePreview(),
+ )}
>
Preview Proposed Changes
@@ -392,8 +395,7 @@ export const BulkUpdateFromQueryModal: FunctionComponent handleCommit())}
>
Update {formatNumber(impactedRecordCount)} {pluralizeFromNumber('Record', impactedRecordCount)}
@@ -431,6 +433,7 @@ export const BulkUpdateFromQueryModal: FunctionComponent
diff --git a/libs/features/query/src/QueryResults/QueryResults.tsx b/libs/features/query/src/QueryResults/QueryResults.tsx
index 3448245fd..b450f7d5c 100644
--- a/libs/features/query/src/QueryResults/QueryResults.tsx
+++ b/libs/features/query/src/QueryResults/QueryResults.tsx
@@ -54,6 +54,7 @@ import {
ToolbarItemGroup,
Tooltip,
buildResultsExport,
+ getAriaKeyshortcuts,
getModifierKey,
useConfirmation,
} from '@jetstream/ui';
@@ -125,6 +126,7 @@ export const QueryResults = React.memo(() => {
sobject?: { name: string; label: string };
}>();
const [soqlPanelOpen, setSoqlPanelOpen] = useState(false);
+ const soqlPanelButtonRef = useRef(null);
const [soql, setSoql] = useState('');
const [sobject, setSobject] = useState>(null);
const [parsedQuery, setParsedQuery] = useState>(null);
@@ -726,6 +728,7 @@ export const QueryResults = React.memo(() => {
className="slds-button slds-button_brand slds-m-right_x-small"
to={{ pathname: APP_ROUTES.QUERY.ROUTE, search: APP_ROUTES.QUERY.SEARCH_PARAM }}
state={{ soql }}
+ aria-keyshortcuts={getAriaKeyshortcuts([getModifierKey(), 'shift', 'enter'])}
>
Back
@@ -733,6 +736,7 @@ export const QueryResults = React.memo(() => {
{
SOQL Query
- executeQuery(soql, SOURCE_RELOAD, { isTooling })}
- disabled={!!(loading || errorMessage)}
- title="Re-run the current query"
+
+
+
+ }
>
-
-
Reload
-
+
executeQuery(soql, SOURCE_RELOAD, { isTooling })}
+ disabled={!!(loading || errorMessage)}
+ aria-keyshortcuts={getAriaKeyshortcuts([getModifierKey(), 'enter'])}
+ title="Re-run the current query"
+ >
+
+ Reload
+
+
@@ -806,6 +820,7 @@ export const QueryResults = React.memo(() => {
isOpen={soqlPanelOpen}
selectedOrg={selectedOrg}
sObject={allowContentDownload.sobjectName || ''}
+ returnFocusTo={soqlPanelButtonRef}
onClosed={() => setSoqlPanelOpen(false)}
executeQuery={(soql, tooling) => executeQuery(soql, SOURCE_MANUAL, { isTooling: tooling })}
onOpenHistory={handleOpenHistory}
diff --git a/libs/features/query/src/QueryResults/QueryResultsAttachmentDownload.tsx b/libs/features/query/src/QueryResults/QueryResultsAttachmentDownload.tsx
index f2a91310d..eb5c7f7fd 100644
--- a/libs/features/query/src/QueryResults/QueryResultsAttachmentDownload.tsx
+++ b/libs/features/query/src/QueryResults/QueryResultsAttachmentDownload.tsx
@@ -18,7 +18,7 @@ import {
} from '@jetstream/shared/ui-utils';
import { getErrorMessage, getRecordIdFromAttributes, pluralizeIfMultiple } from '@jetstream/shared/utils';
import { AsyncJobNew, BinaryDownloadCompatibleObjectsSchema, Maybe, SalesforceOrgUi, SalesforceRecord } from '@jetstream/types';
-import { Icon, Modal, Radio, RadioGroup, ScopedNotification, Tooltip } from '@jetstream/ui';
+import { ariaDisabledButtonProps, Icon, Modal, Radio, RadioGroup, ScopedNotification, Tooltip } from '@jetstream/ui';
import { fromJetstreamEvents, useAmplitude } from '@jetstream/ui-core';
import { Fragment, FunctionComponent, useEffect, useState } from 'react';
import z from 'zod';
@@ -264,7 +264,8 @@ export const QueryResultsAttachmentDownload: FunctionComponent
handleModalClose(true)} disabled={isDownloading}>
Cancel
-
+ {/* Stays focusable while its own click disables it — native disabled would drop focus to */}
+ handleDownload())}>
{isDownloading ? 'Preparing Download...' : 'Download'}
diff --git a/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexGenerateOptions.tsx b/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexGenerateOptions.tsx
index 5d17684da..6d156f682 100644
--- a/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexGenerateOptions.tsx
+++ b/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexGenerateOptions.tsx
@@ -90,6 +90,7 @@ export const QueryResultsGetRecAsApexGenerateOptions: FunctionComponent
setOptions({ ...options, tabSize: Number(event.target.value) })}
diff --git a/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexModal.tsx b/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexModal.tsx
index 7c167f0fe..f79cd1e39 100644
--- a/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexModal.tsx
+++ b/libs/features/query/src/QueryResults/QueryResultsGetRecAsApexModal.tsx
@@ -3,9 +3,8 @@ import { logger } from '@jetstream/shared/client-logger';
import { describeSObject } from '@jetstream/shared/data';
import { useNonInitialEffect } from '@jetstream/shared/ui-utils';
import { Field, FieldType, SalesforceOrgUi } from '@jetstream/types';
-import { AxeIllustration, EmptyState, Grid, GridCol, Icon, Modal, Spinner } from '@jetstream/ui';
+import { AxeIllustration, CopyToClipboard, EmptyState, Grid, GridCol, Modal, Spinner } from '@jetstream/ui';
import { MonacoEditor } from '@jetstream/ui-core';
-import copyToClipboard from 'copy-to-clipboard';
import { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { RecordToApexOptionsInitialOptions, recordToApex, recordsToApex } from '../utils/query-apex-utils';
import QueryResultsGetRecAsApexFieldOptions from './QueryResultsGetRecAsApexFieldOptions';
@@ -81,14 +80,6 @@ export const QueryResultsGetRecAsApexModal: FunctionComponent ({ ...options, ...partialOptions }));
}, []);
- async function handleCopyToClipboard() {
- try {
- await copyToClipboard(apex, { format: 'text/plain' });
- } catch (ex) {
- logger.warn('[COPY TO CLIPBOARD ERROR]', ex);
- }
- }
-
function handleEditorChange(value?: string, _event?: unknown) {
setApex(value || '');
}
@@ -131,10 +122,14 @@ export const QueryResultsGetRecAsApexModal: FunctionComponent
-
-
- Copy to Clipboard
-
+ {/* The shared control keeps focus after copying and announces the success */}
+
{
setBatchSize(parseInt(ev.target.value.replaceAll(REGEX.NOT_NUMERIC, '') || '0', 10));
}}
@@ -75,6 +74,18 @@ export const QueryResultsMoreActions: FunctionComponent(false);
+ const triggerRef = useRef(null);
+ // Closing the bulk update modal after a deploy refreshes the records, which disables this trigger
+ // while the query re-runs — and a disabled button cannot take the focus the modal hands back. The
+ // hand-off is finished here once the trigger is enabled again.
+ const focusTriggerWhenEnabledRef = useRef(false);
+
+ useEffect(() => {
+ if (!disabled && focusTriggerWhenEnabledRef.current) {
+ focusTriggerWhenEnabledRef.current = false;
+ triggerRef.current?.focus();
+ }
+ }, [disabled]);
function handleAction(id: 'bulk-delete' | 'bulk-undelete' | 'get-as-apex' | 'open-in-new-tab' | 'bulk-update' | 'new-record') {
logger.log({ id, selectedRows });
@@ -214,7 +225,10 @@ export const QueryResultsMoreActions: FunctionComponent;
onClosed: () => void;
executeQuery: (soql: string, isTooling: boolean) => void;
onOpenHistory: (type: fromQueryHistoryState.QueryHistoryType) => void;
@@ -70,6 +72,7 @@ export const QueryResultsSoqlPanel: FunctionComponent
+
diff --git a/libs/features/record-type-manager/src/lib/editor/RecordTypeManagerEditorField.tsx b/libs/features/record-type-manager/src/lib/editor/RecordTypeManagerEditorField.tsx
deleted file mode 100644
index 45abc5829..000000000
--- a/libs/features/record-type-manager/src/lib/editor/RecordTypeManagerEditorField.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { css } from '@emotion/react';
-import { getNameOrNameAndLabelFromObj } from '@jetstream/shared/utils';
-import { Grid } from '@jetstream/ui';
-import { PicklistFieldEntry, SobjectWithPicklistValues } from '../types/record-types.types';
-import { RecordTypeManagerEditorFieldItem } from './RecordTypeManagerEditorFieldItem';
-
-export interface RecordTypeManagerEditorFieldProps {
- picklistFieldEntry: PicklistFieldEntry;
- recordTypeValues: SobjectWithPicklistValues['recordTypeValues'];
- onSelectAll: (fieldName: string, recordType: string, value: boolean) => void;
- onSelect: (fieldName: string, recordType: string, picklistValue: string, value: boolean) => void;
- onChangeDefaultValue: (fieldName: string, recordType: string, value: string) => void;
-}
-
-export function RecordTypeManagerEditorField({
- picklistFieldEntry,
- recordTypeValues,
- onSelectAll,
- onSelect,
- onChangeDefaultValue,
-}: RecordTypeManagerEditorFieldProps) {
- return (
-
-
{getNameOrNameAndLabelFromObj(picklistFieldEntry, 'fieldName', 'fieldLabel')}
-
-
- {Object.entries(recordTypeValues).map(([recordTypeName, { picklistValues }]) => (
-
- ))}
-
-
-
- );
-}
diff --git a/libs/features/record-type-manager/src/lib/editor/RecordTypeManagerEditorFieldItem.tsx b/libs/features/record-type-manager/src/lib/editor/RecordTypeManagerEditorFieldItem.tsx
index 6203c8b65..2c8a871a0 100644
--- a/libs/features/record-type-manager/src/lib/editor/RecordTypeManagerEditorFieldItem.tsx
+++ b/libs/features/record-type-manager/src/lib/editor/RecordTypeManagerEditorFieldItem.tsx
@@ -10,6 +10,11 @@ const BLANK_LIST_ITEM: ListItem = { id: SFDC_BLANK_PICKLIST_VALUE, label: '--Non
interface RecordTypeManagerEditorFieldItemProps {
label: string;
+ /**
+ * Full context for screen readers (e.g. "Status field — Sales record type"): the visible heading
+ * only shows one half, the other half lives in the accordion section title far above.
+ */
+ contextLabel?: string;
picklistFieldEntry: PicklistFieldEntry;
recordTypeName: string;
picklistValues: Record
;
@@ -20,6 +25,7 @@ interface RecordTypeManagerEditorFieldItemProps {
export function RecordTypeManagerEditorFieldItem({
label,
+ contextLabel,
recordTypeName,
picklistValues,
picklistFieldEntry,
@@ -50,7 +56,11 @@ export function RecordTypeManagerEditorFieldItem({
);
return (
+ // Grouped-checkbox pattern: entering the group announces which picklist field and record type
+ // these value checkboxes (and the Default Value combobox) belong to
handleDeploy())}
>
{buttonLabel}
);
if (!hasModifiedValue) {
-
{deployButton} ;
+ return
{deployButton} ;
}
if (!configurationErrors) {
diff --git a/libs/features/record-type-manager/src/lib/utils/editor.utils.tsx b/libs/features/record-type-manager/src/lib/utils/editor.utils.tsx
index 912ad12b4..2cda1ba9b 100644
--- a/libs/features/record-type-manager/src/lib/utils/editor.utils.tsx
+++ b/libs/features/record-type-manager/src/lib/utils/editor.utils.tsx
@@ -48,6 +48,7 @@ export const getAccordionFieldSection = ({
{recordTypes.map(([recordTypeName, recordTypeValue]) => (
(
= ({ selectedOrg, whichOrg, onChange }) => {
+ const showingAll = whichOrg === 'ALL';
+ // Empty until the user toggles — a live region that mounts already containing text would announce
+ // the current scope every time the history opens
+ const [statusMessage, setStatusMessage] = useState('');
+
+ function handleToggle() {
+ const nextWhichOrg = showingAll ? 'SELECTED' : 'ALL';
+ onChange(nextWhichOrg);
+ setStatusMessage(nextWhichOrg === 'ALL' ? 'Showing history from all orgs' : `Showing history from ${selectedOrg.label}`);
+ }
+
return (
-
- {whichOrg === 'ALL' && (
-
- Showing from All Orgs .
- onChange('SELECTED')}>
- Limit to selected org
-
-
- )}
- {whichOrg === 'SELECTED' && (
-
- Showing from
- onChange('ALL')}>
- Show from all orgs
-
-
- )}
-
+
+ {/* One stable button element for both states: swapping between two conditional buttons
+ unmounted the control mid-click and dropped keyboard focus to */}
+ Showing from {showingAll ?
All Orgs :
}
+
+ {showingAll ? 'Limit to selected org' : 'Show from all orgs'}
+
+
+
);
};
diff --git a/libs/features/salesforce-api/src/SalesforceApiHistoryModal.tsx b/libs/features/salesforce-api/src/SalesforceApiHistoryModal.tsx
index 15c8f00c5..35ca0482a 100644
--- a/libs/features/salesforce-api/src/SalesforceApiHistoryModal.tsx
+++ b/libs/features/salesforce-api/src/SalesforceApiHistoryModal.tsx
@@ -113,6 +113,7 @@ export const SalesforceApiHistoryModal = ({ selectedOrg, onSubmit, onClose }: Sa
item.key === selectedItemKey}
subheadingPlaceholder
@@ -177,7 +178,11 @@ export const SalesforceApiHistoryModal = ({ selectedOrg, onSubmit, onClose }: Sa
{selectedHistoryItem.request.method}
-
+
{selectedHistoryItem.request.url}
@@ -188,6 +193,7 @@ export const SalesforceApiHistoryModal = ({ selectedOrg, onSubmit, onClose }: Sa
Request Headers{' '}
@@ -204,7 +210,12 @@ export const SalesforceApiHistoryModal = ({ selectedOrg, onSubmit, onClose }: Sa
{selectedHistoryItem.request.body && (
<>
- Request Body
+ Request Body{' '}
+
>
handleSubmit()}
disabled={loading || !!headersErrorMessage || !!bodyErrorMessage}
>
@@ -277,6 +279,8 @@ export const SalesforceApiRequest: FunctionComponent
{request.method}
-
+
{request.url}
@@ -60,7 +64,13 @@ export const SalesforceApiResponse: FunctionComponent
Response Headers
- {results?.headers && }
+ {results?.headers && (
+
+ )}
Response Body
- {results?.body && }
+ {results?.body && (
+
+ )}
= () => {
}
>
-
+
Download
diff --git a/libs/features/teams/src/lib/TeamDashboard/TeamAuditLogModal.tsx b/libs/features/teams/src/lib/TeamDashboard/TeamAuditLogModal.tsx
index a44d8e1c2..88257c7f7 100644
--- a/libs/features/teams/src/lib/TeamDashboard/TeamAuditLogModal.tsx
+++ b/libs/features/teams/src/lib/TeamDashboard/TeamAuditLogModal.tsx
@@ -1,6 +1,6 @@
import { downloadTeamAuditLogsCsv, getTeamAuditLogs } from '@jetstream/shared/data';
import { AuditLogPageResponse, AuditLogUserFacing } from '@jetstream/types';
-import { DatePicker, Modal, ScopedNotification, Spinner } from '@jetstream/ui';
+import { ariaDisabledButtonProps, DatePicker, Modal, ScopedNotification, Spinner } from '@jetstream/ui';
import { endOfMonth } from 'date-fns/endOfMonth';
import { format } from 'date-fns/format';
import { parseISO } from 'date-fns/parseISO';
@@ -129,7 +129,8 @@ export function TeamAuditLogModal({ teamId, onClose }: TeamAuditLogModalProps) {
size="lg"
className="slds-p-around_medium slds-is-relative"
footer={
-
+ // Stays focusable while its own click disables it — native disabled would drop focus to
+ handleDownloadCsv())}>
{csvLoading ? 'Downloading...' : 'Download CSV'}
}
diff --git a/libs/features/teams/src/lib/TeamDashboard/TeamDashboard.tsx b/libs/features/teams/src/lib/TeamDashboard/TeamDashboard.tsx
index 639633960..1c82dd215 100644
--- a/libs/features/teams/src/lib/TeamDashboard/TeamDashboard.tsx
+++ b/libs/features/teams/src/lib/TeamDashboard/TeamDashboard.tsx
@@ -65,7 +65,6 @@ export function TeamDashboard() {
const [loading, setLoading] = useState(true);
const [team, setTeam] = useState();
const [loginConfiguration, setLoginConfiguration] = useState();
- const [loginConfigurationKey, setLoginConfigurationKey] = useState(new Date().getTime());
const [domains, setDomains] = useState([]);
const [ssoConfig, setSsoConfig] = useState(null);
const [loadingError, setLoadingError] = useState(null);
@@ -162,7 +161,9 @@ export function TeamDashboard() {
setTeam(updatedTeam);
const loginConfig = TeamLoginConfigSchema.parse(updatedTeam.loginConfig || {});
setLoginConfiguration(loginConfig);
- setLoginConfigurationKey(new Date().getTime());
+ // Deliberately NOT re-keyed: remounting the form on save discarded keyboard focus. The form
+ // re-baselines its own dirty state once the save resolves.
+ fireToast({ type: 'success', message: 'Login configuration saved' });
}
async function handleTeamGlobalAction(action: TeamGlobalAction) {
@@ -356,7 +357,6 @@ export function TeamDashboard() {
{loginConfiguration && (
— land on the neighbouring domain's last control, or the Add Domain button when none is left.
+ */
+ async function handleDelete(domain: DomainVerification, triggerElement?: HTMLElement) {
+ const row = triggerElement?.closest('li');
+ const adjacentRow = (row?.previousElementSibling ?? row?.nextElementSibling) as HTMLElement | null;
if (
await ConfirmationModalPromise({
content: 'Are you sure you want to delete this domain?',
@@ -53,6 +59,14 @@ export function TeamDomainConfiguration({ teamId, domains, hasSsoEnabled, onChan
// loadVerifications();
fireToast({ type: 'success', message: 'Domain deleted successfully' });
onChange('DELETE', domain);
+ window.setTimeout(() => {
+ if (adjacentRow?.isConnected) {
+ const controls = adjacentRow.querySelectorAll('button, a[href], input, [tabindex]');
+ (controls[controls.length - 1] ?? adjacentRow).focus();
+ return;
+ }
+ document.getElementById(ADD_DOMAIN_BUTTON_ID)?.focus();
+ });
} catch (ex) {
fireToast({ type: 'error', message: getErrorMessage(ex) });
}
@@ -71,7 +85,12 @@ export function TeamDomainConfiguration({ teamId, domains, hasSsoEnabled, onChan
icon={{ type: 'standard', icon: 'people' }}
actions={
canUpdate ? (
- setAddDomainModalOpen(true)}>
+ setAddDomainModalOpen(true)}
+ >
Add Domain
) : undefined
@@ -98,7 +117,8 @@ export function TeamDomainConfiguration({ teamId, domains, hasSsoEnabled, onChan
handleDelete(domain)}
+ aria-label={`Delete domain ${domain.domain}`}
+ onClick={(event) => handleDelete(domain, event.currentTarget)}
>
@@ -128,6 +148,7 @@ export function TeamDomainConfiguration({ teamId, domains, hasSsoEnabled, onChan
setVerifyModalOpen(domain)}
>
Verify
@@ -135,7 +156,8 @@ export function TeamDomainConfiguration({ teamId, domains, hasSsoEnabled, onChan
handleDelete(domain)}
+ aria-label={`Delete domain ${domain.domain}`}
+ onClick={(event) => handleDelete(domain, event.currentTarget)}
>
@@ -161,6 +183,9 @@ export function TeamDomainConfiguration({ teamId, domains, hasSsoEnabled, onChan
);
}
+/** Focus target after the last domain row is deleted (its own delete button unmounts with it) */
+const ADD_DOMAIN_BUTTON_ID = 'team-domain-add-domain';
+
const FormSchema = z.object({
domain: z
.string()
@@ -199,8 +224,15 @@ function AddDomainModal({ onClose, onSave }: { onClose: () => void; onSave: (dom
}
>