fix(editor): Improve screen reader support for scope selector tool pills (#37159)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Savelii
2026-08-27 14:18:50 +00:00
committed by GitHub
parent 5b9b3321dc
commit e644562969
3 changed files with 105 additions and 2 deletions
@@ -2621,7 +2621,9 @@
"oauth.consentView.scopes.group.dataTables": "Data tables",
"oauth.consentView.scopes.group.projectsAndFolders": "Projects and folders",
"oauth.consentView.scopes.tools.count": "{count} tool | {count} tools",
"oauth.consentView.scopes.tools.enabled": "enabled",
"oauth.consentView.scopes.tools.enabledOf": "{enabled} of {total} tools enabled",
"oauth.consentView.scopes.tools.notEnabled": "not enabled",
"oauth.consentView.allow": "Allow access",
"oauth.consentView.redirectWarning.title": "After you allow, access to this instance is sent to:",
"oauth.consentView.redirectWarning.confirm": "I recognize and trust this URL",
@@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue';
import { capitalCase } from 'change-case';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import { CollapsibleRoot, CollapsibleTrigger, VisuallyHidden } from 'reka-ui';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
@@ -47,7 +47,7 @@ const props = withDefaults(
* Tool names each scope unlocks. When provided, group rows show a tool
* count pill whose popover lists the tools enabled by the current
* selection. Expected i18n keys under the prefix: `.tools.count`,
* `.tools.enabledOf`.
* `.tools.enabled`, `.tools.enabledOf`, `.tools.notEnabled`.
*/
scopeTools?: Record<string, string[]>;
}>(),
@@ -345,8 +345,12 @@ function toggleScope(scope: S, checked: boolean) {
:data-test-id="`scope-group-${group.key}`"
@update:model-value="(checked: boolean) => toggleGroup(group, checked)"
/>
<!-- `as-child` makes the pill itself the tooltip trigger, so keyboard
focus opens the popover and `aria-describedby` lands on the focused
element for screen readers. -->
<N8nTooltip
v-if="groupTools(group).length > 0"
as-child
placement="right"
:show-after="150"
:content-class="$style['tools-tooltip']"
@@ -378,6 +382,14 @@ function toggleScope(scope: S, checked: boolean) {
:class="$style['tool-icon']"
/>
<span :class="$style['tool-name']">{{ tool }}</span>
<!-- State icons are aria-hidden; expose enabled state as text. -->
<VisuallyHidden>
{{
groupEnabledTools(group).has(tool)
? baseText('tools.enabled')
: baseText('tools.notEnabled')
}}
</VisuallyHidden>
</div>
</div>
</template>
@@ -428,6 +440,8 @@ function toggleScope(scope: S, checked: boolean) {
</template>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/focus';
/* Option and checkbox labels render at 12px here, one step below the body copy. */
.selector {
--radio-group-item--label--font-size: var(--font-size--2xs);
@@ -530,6 +544,8 @@ function toggleScope(scope: S, checked: boolean) {
border-color: var(--color--primary);
color: var(--color--text--shade-1);
}
@include focus.focus-visible-ring-offset;
}
/* the shared tooltip caps content at 180px and centers it; tool identifiers need more room */
@@ -4,6 +4,7 @@ import { useConsentStore } from '@/app/stores/consent.store';
import OAuthConsentView from '@/app/views/OAuthConsentView.vue';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { within } from '@testing-library/vue';
vi.mock('@n8n/rest-api-client/api/consent');
@@ -348,6 +349,90 @@ describe('OAuthConsentView', () => {
expect(queryByTestId('scope-group-tools-workflows')).not.toBeInTheDocument();
});
it('should open the tools popover on keyboard focus and link it to the pill', async () => {
const detailsWithTools = {
...scopedDetails,
scopeTools: {
'workflow:read': ['search_workflows', 'get_workflow_details'],
'workflow:write': ['update_workflow', 'search_workflows'],
'execution:read': ['get_workflow_execution'],
},
};
consentStore.consentDetails = detailsWithTools;
consentStore.fetchConsentDetails.mockImplementation(async () => {
consentStore.consentDetails = detailsWithTools;
return detailsWithTools;
});
const { getByTestId, queryByTestId } = renderComponent();
await waitAllPromises();
await userEvent.click(getByTestId('scopes-tree-toggle'));
const pill = getByTestId('scope-group-tools-workflows');
expect(pill).toHaveAttribute('tabindex', '0');
pill.focus();
await waitAllPromises();
const popover = getByTestId('scope-group-tools-popover-workflows');
expect(popover).toBeInTheDocument();
// The trigger is described by a hidden role="tooltip" node holding the
// flattened popover text — this is what a screen reader announces.
const describedBy = pill.getAttribute('aria-describedby');
expect(describedBy).toBeTruthy();
const description = document.getElementById(describedBy as string);
expect(description).toHaveAttribute('role', 'tooltip');
expect(description).toHaveTextContent('3 of 3 tools enabled');
pill.blur();
await waitAllPromises();
expect(queryByTestId('scope-group-tools-popover-workflows')).not.toBeInTheDocument();
});
it('should expose per-tool enabled state as text in the tools popover', async () => {
const detailsWithTools = {
...scopedDetails,
scopeTools: {
'workflow:read': ['search_workflows', 'get_workflow_details'],
'workflow:write': ['update_workflow', 'search_workflows'],
'execution:read': ['get_workflow_execution'],
},
};
consentStore.consentDetails = detailsWithTools;
consentStore.fetchConsentDetails.mockImplementation(async () => {
consentStore.consentDetails = detailsWithTools;
return detailsWithTools;
});
const { getByTestId } = renderComponent();
await waitAllPromises();
await userEvent.click(getByTestId('scopes-mode-custom'));
await userEvent.click(getByTestId('scope-group-executions'));
const workflowsPill = getByTestId('scope-group-tools-workflows');
workflowsPill.focus();
await waitAllPromises();
const workflowsPopover = getByTestId('scope-group-tools-popover-workflows');
expect(workflowsPopover).toHaveTextContent('0 of 3 tools enabled');
expect(within(workflowsPopover).getAllByText('not enabled')).toHaveLength(3);
workflowsPill.blur();
await waitAllPromises();
const executionsPill = getByTestId('scope-group-tools-executions');
executionsPill.focus();
await waitAllPromises();
const executionsPopover = getByTestId('scope-group-tools-popover-executions');
expect(executionsPopover).toHaveTextContent('1 of 1 tools enabled');
expect(within(executionsPopover).getAllByText('enabled')).toHaveLength(1);
});
it('should disable Allow when no scopes are selected', async () => {
const { getByTestId, getByLabelText } = renderComponent();
await waitAllPromises();