️(frontend) improve keyboard navigation on modals

- create a temporary Modal component to apply it
a function tracking document update and set
modal elements we want to ignore a tabindex=-1.
- add component tests
- temporary fix. Better to apply them on
cunningham directly
This commit is contained in:
daproclaima
2024-09-06 02:35:52 +02:00
committed by Sebastien Nobour
parent d291e55a9e
commit 0d157d3f2b
12 changed files with 138 additions and 28 deletions

View File

@@ -17,7 +17,7 @@ and this project adheres to
- 🐛(dimail) improve handling of dimail errors on failed mailbox creation #377
- 💬(frontend) fix group member removal text #382
- 💬(frontend) fix add mail domain text #382
- 🐛(frontend) fix keyboard navigation on language picker #379
- 🐛(frontend) fix keyboard navigation #379
## [1.0.2] - 2024-08-30

View File

@@ -0,0 +1,42 @@
import {
Modal as CunninghamModal,
ModalProps,
} from '@openfun/cunningham-react';
import React, { useEffect } from 'react';
// Define a wrapper component that extends ModalProps to accept the same props as the Modal
export const Modal: React.FC<ModalProps> = ({ children, ...props }) => {
// Apply the hook here once for all modals
usePreventFocusVisible(['.c__modal__content']);
return <CunninghamModal {...props}>{children}</CunninghamModal>;
};
/**
* @description used to prevent elements to be navigable by keyboard when only a DOM mutation causes the elements to be
* in the document
* @see https://github.com/numerique-gouv/people/pull/379
*/
export const usePreventFocusVisible = (elements: string[]) => {
useEffect(() => {
const observer = new MutationObserver((mutationsList) => {
mutationsList.forEach(() => {
elements.forEach((selector) =>
document.querySelector(selector)?.setAttribute('tabindex', '-1'),
);
observer.disconnect();
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
};
}, [elements]);
return null;
};

View File

@@ -0,0 +1,80 @@
import { ModalSize } from '@openfun/cunningham-react';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { Modal, usePreventFocusVisible } from '../Modal';
describe('usePreventFocusVisible hook', () => {
const TestComponent = () => {
usePreventFocusVisible(['.test-element']);
return (
<div>
<div className="test-element">Test Element</div>
</div>
);
};
const originalMutationObserver = global.MutationObserver;
const mockDisconnect = jest.fn();
const mutationObserverMock = jest.fn(function MutationObserver(
callback: MutationCallback,
) {
this.observe = () => {
callback([{ type: 'childList' }] as MutationRecord[], this);
};
this.disconnect = mockDisconnect;
});
afterEach(() => jest.clearAllMocks());
beforeAll(
() =>
(global.MutationObserver =
mutationObserverMock as unknown as typeof MutationObserver),
);
afterAll(() => (global.MutationObserver = originalMutationObserver));
test('sets tabindex to -1 on the target elements', () => {
const { unmount } = render(<TestComponent />);
const targetElement = screen.getByText('Test Element');
expect(targetElement).toHaveAttribute('tabindex', '-1');
unmount();
expect(mockDisconnect).toHaveBeenCalled();
});
});
describe('Modal', () => {
test('applies usePreventFocusVisible and sets tabindex', async () => {
render(
<Modal
isOpen={true}
onClose={() => {}}
size={ModalSize.MEDIUM}
title={<h3>Test Modal Title</h3>}
leftActions={<button>Cancel</button>}
rightActions={<button>Submit</button>}
>
<p>Modal content</p>
</Modal>,
{ wrapper: AppWrapper },
);
/* eslint-disable testing-library/no-node-access */
const modalContent = document.querySelector('.c__modal__content');
/* eslint-enable testing-library/no-node-access */
await waitFor(() => {
expect(modalContent).toHaveAttribute('tabindex', '-1');
});
});
});

View File

@@ -484,11 +484,6 @@ input:-webkit-autofill:focus {
/**
* Modal
*/
.c__modal:focus-visible {
outline: none;
box-shadow: none;
}
.c__modal__backdrop {
z-index: 1000;
}

View File

@@ -60,19 +60,17 @@ export const LanguagePicker = () => {
}));
}, [languages]);
/**
* @description prevent select div to receive focus on keyboard navigation so the focus goes directly to inner button
* @see https://github.com/numerique-gouv/people/pull/379
*/
useEffect(() => {
if (!document) {
return;
}
const languagePickerDom = document.querySelector(
'.c__select-language-picker',
);
if (!languagePickerDom?.firstChild?.firstChild) {
return;
}
(languagePickerDom.firstChild.firstChild as HTMLElement).tabIndex = -1;
document
.querySelector('.c__select-language-picker .c__select__wrapper')
?.setAttribute('tabindex', '-1');
}, []);
return (

View File

@@ -1,11 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import {
Button,
Input,
Loader,
Modal,
ModalSize,
} from '@openfun/cunningham-react';
import { Button, Input, Loader, ModalSize } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import React from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
@@ -13,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { useCreateMailDomain } from '@/features/mail-domains';
import { default as MailDomainsLogo } from '../assets/mail-domains-logo.svg';

View File

@@ -2,7 +2,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
import {
Button,
Input,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -19,6 +18,7 @@ import { createGlobalStyle } from 'styled-components';
import { z } from 'zod';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { CreateMailboxParams, useCreateMailbox } from '../../api';
import { MailDomain } from '../../types';

View File

@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -11,6 +10,7 @@ import { createGlobalStyle } from 'styled-components';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import { Modal } from '@/components/Modal';
import { useCunninghamTheme } from '@/cunningham';
import { ChooseRole } from '@/features/teams/member-management';
import { Role, Team } from '@/features/teams/team-management';

View File

@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -10,6 +9,7 @@ import { useRouter } from 'next/navigation';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { useCunninghamTheme } from '@/cunningham';
import { Role, Team } from '@/features/teams/team-management';

View File

@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -9,6 +8,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import { Role } from '@/features/teams/team-management';
import { useUpdateTeamAccess } from '../api/useUpdateTeamAccess';

View File

@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -10,6 +9,7 @@ import { useRouter } from 'next/navigation';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, Text, TextErrors } from '@/components';
import { Modal } from '@/components/Modal';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useRemoveTeam } from '../api/useRemoveTeam';

View File

@@ -1,6 +1,5 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
@@ -9,6 +8,7 @@ import { t } from 'i18next';
import { useState } from 'react';
import { Box, Text } from '@/components';
import { Modal } from '@/components/Modal';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { useUpdateTeam } from '../api';