♿️(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:
committed by
Sebastien Nobour
parent
d291e55a9e
commit
0d157d3f2b
@@ -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
|
||||
|
||||
|
||||
42
src/frontend/apps/desk/src/components/Modal.tsx
Normal file
42
src/frontend/apps/desk/src/components/Modal.tsx
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -484,11 +484,6 @@ input:-webkit-autofill:focus {
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
.c__modal:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.c__modal__backdrop {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user