Skip to content

Commit

Permalink
Port to master
Browse files Browse the repository at this point in the history
  • Loading branch information
Gr3q committed Sep 20, 2024
1 parent 62a162f commit 59f21c9
Show file tree
Hide file tree
Showing 10 changed files with 360 additions and 60 deletions.
1 change: 1 addition & 0 deletions packages/mui-base/src/Portal/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const Portal = React.forwardRef(function Portal(

useEnhancedEffect(() => {
if (!disablePortal) {
// If you change this the one in useModal.handleOpen->resolvedContainer should match.
setMountNode(getContainer(container) || document.body);
}
}, [container, disablePortal]);
Expand Down
99 changes: 89 additions & 10 deletions packages/mui-base/src/unstable_useModal/ModalManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,14 +317,16 @@ describe('ModalManager', () => {
});

describe('container aria-hidden', () => {
let modalRef1;
let modalRef1: HTMLDivElement;
let container2: HTMLDivElement;

beforeEach(() => {
container2 = document.createElement('div');
container2.id = 'container2';
document.body.appendChild(container2);

modalRef1 = document.createElement('div');
modalRef1.id = 'modal1';
container2.appendChild(modalRef1);

modalManager = new ModalManager();
Expand Down Expand Up @@ -386,31 +388,55 @@ describe('ModalManager', () => {
});

it('should add aria-hidden to previous modals', () => {
const modal2 = document.createElement('div');
const modal3 = document.createElement('div');

container2.appendChild(modal2);
container2.appendChild(modal3);

modalManager.add({ ...getDummyModal(), modalRef: modal2 }, container2);
const modal2: Modal = { mount: container2, modalRef: document.createElement('div') };
modal2.modalRef.id = 'modal2Ref';
const modal3: Modal = { mount: container2, modalRef: document.createElement('div') };
modal3.modalRef.id = 'modal3Ref';

container2.appendChild(modal2.modalRef);
container2.appendChild(modal3.modalRef);

// ideally mount must be a child of container, and modalRef must be a child of mount.
// usually mount is worked out out by Portal:
// * if disablePortal is true, mount is modalRef or null (even though types don't allow that).
// it could be the container2 because it is technically the mount (although it doesn't happen in real scenarios).
// * if disablePortal is false, mount is the container if set, or document.body.

// We directly mounted the modalRef to the container2, so
// mount is modalRef or the container too (or null, but typing doesn't allow it).
modalManager.add(modal2, container2);
modalManager.mount(modal2, {});
// Simulate the main React DOM true.
expect(container2.children[0]).toBeAriaHidden();
expect(container2.children[1]).not.toBeAriaHidden();
expect(container2.children[2]).toBeAriaHidden();

modalManager.add({ ...getDummyModal(), modalRef: modal3 }, container2);
// Mount can be container2 itself too because it's technically the mount.
modalManager.add(modal3, container2);
modalManager.mount(modal3, {});
expect(container2.children[0]).toBeAriaHidden();
expect(container2.children[1]).toBeAriaHidden();
expect(container2.children[2]).not.toBeAriaHidden();
});

it('should remove aria-hidden on siblings', () => {
const modal = { ...getDummyModal(), modalRef: container2.children[0] };
// Previous implementation was testing sibling state without siblings, wtf
const modal = { mount: container2, modalRef: modalRef1 };
const sibling1 = document.createElement('div');
const sibling2 = document.createElement('div');

container2.appendChild(sibling1);
container2.appendChild(sibling2);

modalManager.add(modal, container2);
modalManager.mount(modal, {});
expect(container2.children[0]).not.toBeAriaHidden();
expect(container2.children[1]).toBeAriaHidden();
expect(container2.children[2]).toBeAriaHidden();
modalManager.remove(modal);
expect(container2.children[0]).toBeAriaHidden();
expect(container2.children[1]).not.toBeAriaHidden();
expect(container2.children[2]).not.toBeAriaHidden();
});

it('should keep previous aria-hidden siblings hidden', () => {
Expand All @@ -431,5 +457,58 @@ describe('ModalManager', () => {
expect(container2.children[1]).toBeAriaHidden();
expect(container2.children[2]).not.toBeAriaHidden();
});

it('top modal should always be accessible from sublevels', () => {
// simulates modal shown in body
const modal1 = { mount: container2, modalRef: modalRef1 };
const mainContentSibling = document.createElement('div');
container2.appendChild(mainContentSibling);

// simulates modal shown in main content with disablePortal
const modal2 = document.createElement('div');
mainContentSibling.appendChild(modal2);

modalManager.add(modal1, container2);

expect(container2.children[0]).not.toBeAriaHidden();
expect(container2.children[1]).toBeAriaHidden();

modalManager.add({ mount: mainContentSibling, modalRef: modal2 }, mainContentSibling);
expect(container2.children[0]).toBeAriaHidden();
// main content sibling should not be hidden
expect(container2.children[1]).not.toBeAriaHidden();
expect(mainContentSibling.children[0]).not.toBeAriaHidden();
});

it('top modal should always be accessible even if even inside other dialog', () => {
// simulates modal shown in another modal
const modal1 = { mount: container2, modalRef: modalRef1 };
const modal2 = { mount: modalRef1, modalRef: document.createElement('div') };
modal2.modalRef.id = 'modal2';

const modal2Sibling = document.createElement('div');
modal1.modalRef.appendChild(modal2Sibling);

const modal1Sibling = document.createElement('div');
container2.appendChild(modal1Sibling);

modalManager.add(modal1, container2);

expect(container2.children[0]).not.toBeAriaHidden();
expect(container2.children[0].children[0]).not.toBeAriaHidden();
expect(container2.children[1]).toBeAriaHidden();

modal1.modalRef.appendChild(modal2.modalRef);
modalManager.add(modal2, modal1.modalRef);

// modal1
expect(container2.children[0]).not.toBeAriaHidden();
// modal1 sibling
expect(container2.children[1]).toBeAriaHidden();
// modal2 sibling
expect(container2.children[0].children[0]).toBeAriaHidden();
// modal2
expect(container2.children[0].children[1]).not.toBeAriaHidden();
});
});
});
88 changes: 71 additions & 17 deletions packages/mui-base/src/unstable_useModal/ModalManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,22 +56,56 @@ function isAriaHiddenForbiddenOnElement(element: Element): boolean {
return isForbiddenTagName || isInputHidden;
}

function ariaHiddenSiblings(
function ariaHiddenElements(
container: Element,
mountElement: Element,
currentElement: Element,
elementsToExclude: readonly Element[],
show: boolean,
): void {
const blacklist = [mountElement, currentElement, ...elementsToExclude];
let current: Element | null = container;
let previousElement: Element =
container === mountElement ? currentElement : mountElement ?? currentElement;
const html = ownerDocument(container).body.parentElement;
const blacklist = [mountElement, ...elementsToExclude];

// In theory this should not happen anymore.
// in some cases the container and previous element still
// could end up being the same, in this case we just go up 1
if (current === previousElement) {
current = current.parentElement;
}

[].forEach.call(container.children, (element: Element) => {
const isNotExcludedElement = !blacklist.includes(element);
const isNotForbiddenElement = !isAriaHiddenForbiddenOnElement(element);
if (isNotExcludedElement && isNotForbiddenElement) {
ariaHidden(element, show);
while (!!current && html !== current) {
for (let i = 0; i < current.children.length; i+=1) {
const element = current.children[i];
const isNotExcludedElement = blacklist.indexOf(element) === -1;
const isNotForbiddenElement = !isAriaHiddenForbiddenOnElement(element);
const isPreviousElement = element === previousElement;

// We came from here
if (isPreviousElement) {
if (!isNotExcludedElement) {
// If any of the ancestors have aria-hidden applied (e.g. by another Modal)
// there is a chance that we end up with nothing accessible in the element tree.
// So we remove the aria-hidden tag from ancestors so at least the current modal is accessible,
// even tho it's probably undesirable when aria-hidden is not coming from another modal.
if (show) {
ariaHidden(element, !show);
}
// we restore it if it was originally hidden
else {
ariaHidden(element, show);
}
}
} else if (isNotExcludedElement && isNotForbiddenElement) {
ariaHidden(element, show);
}
}
});

previousElement = current;
current = current.parentElement;
}
}

function findIndexOf<T>(items: readonly T[], callback: (item: T) => boolean): number {
Expand Down Expand Up @@ -174,18 +208,32 @@ function handleContainer(containerInfo: Container, props: ManagedModalProps) {
return restore;
}

function getHiddenSiblings(container: Element) {
function getHiddenElements(container: Element) {
const hiddenSiblings: Element[] = [];
[].forEach.call(container.children, (element: Element) => {
if (element.getAttribute('aria-hidden') === 'true') {
hiddenSiblings.push(element);
}
});
const html = ownerDocument(container).body.parentElement;
let current: Element | null = container;

while (current != null && html !== current) {
[].forEach.call(current.children, (element: Element) => {
if (element.getAttribute('aria-hidden') === 'true') {
hiddenSiblings.push(element);
}
});
current = current.parentElement;
}
return hiddenSiblings;
}

interface Modal {
/**
* The immediate child of the container argument {@link ModalManager.add}.
*
* If you pass in {@link modalRef} or the container itself it's also handled
*/
mount: Element;
/**
* The modal element itself.
*/
modalRef: Element;
}

Expand Down Expand Up @@ -213,6 +261,12 @@ export class ModalManager {
this.containers = [];
}

/**
*
* @param modal
* @param container {@link Modal["mount"]}
* @returns
*/
add(modal: Modal, container: HTMLElement): number {
let modalIndex = this.modals.indexOf(modal);
if (modalIndex !== -1) {
Expand All @@ -227,8 +281,8 @@ export class ModalManager {
ariaHidden(modal.modalRef, false);
}

const hiddenSiblings = getHiddenSiblings(container);
ariaHiddenSiblings(container, modal.mount, modal.modalRef, hiddenSiblings, true);
const hiddenSiblings = getHiddenElements(container);
ariaHiddenElements(container, modal.mount, modal.modalRef, hiddenSiblings, true);

const containerIndex = findIndexOf(this.containers, (item) => item.container === container);
if (containerIndex !== -1) {
Expand Down Expand Up @@ -280,7 +334,7 @@ export class ModalManager {
ariaHidden(modal.modalRef, ariaHiddenState);
}

ariaHiddenSiblings(
ariaHiddenElements(
containerInfo.container,
modal.mount,
modal.modalRef,
Expand Down
15 changes: 13 additions & 2 deletions packages/mui-base/src/unstable_useModal/useModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const defaultManager = new ModalManager();
export function useModal(parameters: UseModalParameters): UseModalReturnValue {
const {
container,
disablePortal = false,
disableEscapeKeyDown = false,
disableScrollLock = false,
// @ts-ignore internal logic - Base UI supports the manager as a prop too
Expand Down Expand Up @@ -82,7 +83,13 @@ export function useModal(parameters: UseModalParameters): UseModalReturnValue {
};

const handleOpen = useEventCallback(() => {
const resolvedContainer = getContainer(container) || getDoc().body;
/**
* Resolving this could be simplified (mountNodeRef should take priority when it's set)
* but this will also work because the logic matches {@link Portal}.
*/
const resolvedContainer = disablePortal
? (mountNodeRef.current ?? modalRef.current)?.parentElement ?? getDoc().body
: getContainer(container) || getDoc().body;

manager.add(getModal(), resolvedContainer);

Expand Down Expand Up @@ -112,7 +119,11 @@ export function useModal(parameters: UseModalParameters): UseModalReturnValue {
manager.remove(getModal(), ariaHiddenProp);
}, [ariaHiddenProp, manager]);

React.useEffect(() => {
// We need useLayoutEffect to make sure
// aria-hidden tags have time to get cleaned up properly
// in handleClose->manager.remove->ariaHiddenElements
// in the case someone unmounts the Modal higher up the tree
React.useLayoutEffect(() => {
return () => {
handleClose();
};
Expand Down
5 changes: 5 additions & 0 deletions packages/mui-base/src/unstable_useModal/useModal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ export type UseModalParameters = {
* so it's simply `document.body` most of the time.
*/
container?: PortalProps['container'];
/**
* The `children` will be under the DOM hierarchy of the parent component.
* @default false
*/
disablePortal?: PortalProps['disablePortal'];
/**
* If `true`, hitting escape will not fire the `onClose` callback.
* @default false
Expand Down
Loading

0 comments on commit 59f21c9

Please sign in to comment.