mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user