Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[material-ui][Modal] Apply aria-hidden to the correct elements when Modals are mounted to other places than document.body #43318

Open
wants to merge 49 commits into
base: master
Choose a base branch
from

Conversation

Gr3q
Copy link

@Gr3q Gr3q commented Aug 15, 2024

Fixes #19450

Lets do #34165 again. Before if someone used disablePortal and opened a modal the whole page became inaccessible to Screen Readers because aria-hidden only got applied to siblings of document.body, in this case all of them. This PR fixes that.

Changes:

  • Make sure we pass in the correct container if disablePortal is used.
  • If the modal container is deep in the tree we need to make sure every sibling in every ancestor level is aria-hidden and every aria-hidden we added gets cleaned up properly on unmount.
  • In addition because we don't differentiate between aria-hidden tags that got added by modals and added by devs, we need to pierce through (remove) every aria-hidden attribute in every ancestor of the last modal container; this is to prevent no accessible elements on a page when multiple modals are present.

Known problems:

  • You will have a bad time if you put your dialog in a container next to 1000s of siblings - bad idea before, bad idea now.
  • Previous bug still stands when you mess with aria-hidden states in the tree outside your modal, when the modal gets removed your changes won't be preserved (because the modal will restore the state when it was added)

I'll port this to next too when I have time.

@mui-bot
Copy link

mui-bot commented Aug 15, 2024

Netlify deploy preview

https://deploy-preview-43318--material-ui.netlify.app/

@material-ui/core: parsed: +0.06% , gzip: +0.10%
Dialog: parsed: +0.36% , gzip: +0.48%
Drawer: parsed: +0.36% , gzip: +0.48%
TextField: parsed: +0.23% , gzip: +0.31%
Popover: parsed: +0.36% , gzip: +0.43%
SwipeableDrawer: parsed: +0.34% , gzip: +0.42%

Bundle size report

Details of bundle changes (Toolpad)
Details of bundle changes

Generated by 🚫 dangerJS against b3d9a0a

@Gr3q Gr3q changed the title [Modal] Apply aria-hidden to the correct Elements in the tree when Modals are mounted to to other places than document.body [ModalManager] Apply aria-hidden to the correct Elements in the tree when Modals are mounted to to other places than document.body Aug 15, 2024
@Gr3q Gr3q changed the title [ModalManager] Apply aria-hidden to the correct Elements in the tree when Modals are mounted to to other places than document.body [ModalManager] Apply aria-hidden to the correct elements in the tree when Modals are mounted to other places than document.body Aug 15, 2024
@aarongarciah aarongarciah added component: modal This is the name of the generic UI component, not the React module! package: material-ui Specific to @mui/material accessibility a11y labels Aug 16, 2024
@aarongarciah aarongarciah changed the title [ModalManager] Apply aria-hidden to the correct elements in the tree when Modals are mounted to other places than document.body [material-ui][Modal] Apply aria-hidden to the correct elements when Modals are mounted to other places than document.body Aug 16, 2024
@aarongarciah aarongarciah self-assigned this Aug 16, 2024
@zannager zannager requested a review from mnajdova August 16, 2024 10:36
@Gr3q
Copy link
Author

Gr3q commented Aug 17, 2024

Your argos check seems to be borked especially I made no changed that would affect visuals.

@aarongarciah aarongarciah removed their assignment Sep 11, 2024
@ZeeshanTamboli ZeeshanTamboli changed the base branch from v5.x to master September 20, 2024 06:41
@ZeeshanTamboli ZeeshanTamboli changed the base branch from master to v5.x September 20, 2024 06:42
Copy link
Member

@ZeeshanTamboli ZeeshanTamboli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Gr3q, can you point the PR to the master branch, our current development branch for releases? I'll review it once that's done.

Your argos check seems to be borked especially I made no changed that would affect visuals.

I think its because the target branch is v5 and argos checks against master.

@Gr3q Gr3q changed the base branch from v5.x to master September 20, 2024 10:31
@Gr3q
Copy link
Author

Gr3q commented Sep 21, 2024

Should I not touch the unstable folder in the future? Could you explain why does it exist?

@ZeeshanTamboli
Copy link
Member

ZeeshanTamboli commented Sep 21, 2024

Should I not touch the unstable folder in the future? Could you explain why does it exist?

I reverted the changes in Base UI (mui-base directory). It included the unstable folder because Base UI was still in beta. We no longer maintain Base UI in this repository—it's now legacy and will be removed in the future (timeline unknown). Material UI used to depend on Base UI, but we've since separated the code, causing duplication. We don't plan to make any further changes to Base UI here. The issue is related to Material UI's Modal. Base UI with headless components is now being developed in a separate repo: mui/base-ui, which will be integrated into Material UI once stable.

Copy link
Member

@ZeeshanTamboli ZeeshanTamboli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Gr3q Thanks for the pull request.

In addition because we don't differentiate between aria-hidden tags that got added by modals and added by devs, we need to pierce through (remove) every aria-hidden attribute in every ancestor of the last modal container; this is to prevent no accessible elements on a page when multiple modals are present.

I didn't understand this. Can you please explain more clearly with an example?

Previous bug still stands when you mess with aria-hidden states in the tree outside your modal, when the modal gets removed your changes won't be preserved (because the modal will restore the state when it was added)

What bug is this? Can you give a bug reproduction in the form of StackBlitz or CodeSandbox?

packages/mui-material/src/Modal/ModalManager.test.ts Outdated Show resolved Hide resolved
packages/mui-material/src/Modal/ModalManager.test.ts Outdated Show resolved Hide resolved
packages/mui-material/src/Modal/ModalManager.test.ts Outdated Show resolved Hide resolved
packages/mui-material/src/Modal/ModalManager.test.ts Outdated Show resolved Hide resolved
packages/mui-material/src/Modal/ModalManager.ts Outdated Show resolved Hide resolved
packages/mui-material/src/Modal/ModalManager.ts Outdated Show resolved Hide resolved
packages/mui-material/src/Modal/useModal.types.ts Outdated Show resolved Hide resolved
packages/mui-material/src/Portal/Portal.tsx Outdated Show resolved Hide resolved
Gr3q and others added 5 commits September 22, 2024 11:53
Co-authored-by: Zeeshan Tamboli <[email protected]>
Signed-off-by: Attila Greguss <[email protected]>
Co-authored-by: Zeeshan Tamboli <[email protected]>
Signed-off-by: Attila Greguss <[email protected]>
Co-authored-by: Zeeshan Tamboli <[email protected]>
Signed-off-by: Attila Greguss <[email protected]>
@Gr3q
Copy link
Author

Gr3q commented Oct 26, 2024

@Gr3q, I just wanted to inform you and see if that could simplify things. No need to start from scratch.

If you want to keep the previous behaviour - make only the toplevel modal accessible for screenreaders and to be able to trap focus - then I can't.

But I can't say for sure without some real screenreader testing with aria-modals set, like how they handle focus, tabbing, multiple aria-modals, nested aria-modals etc. I could only simplify the code here if they are capable of doing the work for us.

Copy link
Member

@ZeeshanTamboli ZeeshanTamboli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This page is not scrollable: https://deploy-preview-43318--material-ui.netlify.app/material-ui/react-modal/. Maybe some logic is wrong.

@Gr3q
Copy link
Author

Gr3q commented Oct 28, 2024

This page is not scrollable: https://deploy-preview-43318--material-ui.netlify.app/material-ui/react-modal/. Maybe some logic is wrong.

I narrowed it down to the server-side modal on the page. I'll check where it's going wrong.

Edit: The server-side modal in the docs is always set to open and disableScrollLock is not set on it, so it's technically working as intended. It didn't work as intended before, so technically this is a breaking change?

@Gr3q Gr3q requested a review from ZeeshanTamboli October 28, 2024 12:45
@ZeeshanTamboli
Copy link
Member

The server-side modal in the docs is always set to open and disableScrollLock is not set on it, so it's technically working as intended. It didn't work as intended before, so technically this is a breaking change?

The issue seems to be that the server-side modal has a custom container (container={() => rootRef.current!}). Previously, handleContainer considered this custom container, so overflow: hidden wasn’t applied to it (because it didn't overflow), and scrolling wasn’t locked. Now, it's always the body element, so overflow: hidden is applied, locking scrolling. This is the breaking change I was concerned about in this discussion.

@Gr3q
Copy link
Author

Gr3q commented Nov 4, 2024

The issue seems to be that the server-side modal has a custom container (container={() => rootRef.current!}). Previously, handleContainer considered this custom container, so overflow: hidden wasn’t applied to it (because it didn't overflow), and scrolling wasn’t locked. Now, it's always the body element, so overflow: hidden is applied, locking scrolling. This is the breaking change I was concerned about in #43318 (comment).

@ZeeshanTamboli I'm not sure I have a good answer to that.

My assumption is that disableScrollLock is made to prevent scrolling on the page when a modal is open, not on the immediate (parent) container, because that doesn't really have the desired effect (doesn't actually prevent scrolling). Maybe my assumption is wrong.

We made that change because it was not behaving as desired for disablePortal. I treat disablePortal and container the same, because what you can do with disablePortal={true} you can do it directly with container. For example in the server-side example if you remove setting container it will display exactly the same as with container.

If they are meant to be different I'm not sure what is the best course of action, container prop has nothing in it's docstring indicating that it will affect scroll locking behaviour in any way.

In short I can't come up with have any use-cases where the behaviour you are describing is useful by myself. If can provide me one or two I can start implementing something that matches those use cases too

@ZeeshanTamboli
Copy link
Member

ZeeshanTamboli commented Nov 7, 2024

@ZeeshanTamboli I'm not sure I have a good answer to that.

My assumption is that disableScrollLock is made to prevent scrolling on the page when a modal is open, not on the immediate (parent) container, because that doesn't really have the desired effect (doesn't actually prevent scrolling). Maybe my assumption is wrong.

We made that change because it was not behaving as desired for disablePortal. I treat disablePortal and container the same, because what you can do with disablePortal={true} you can do it directly with container. For example in the server-side example if you remove setting container it will display exactly the same as with container.

If they are meant to be different I'm not sure what is the best course of action, container prop has nothing in it's docstring indicating that it will affect scroll locking behaviour in any way.

In short I can't come up with have any use-cases where the behaviour you are describing is useful by myself. If can provide me one or two I can start implementing something that matches those use cases too

As per the current production code, the prevention of scrolling is on the container, where the scrollContainer in the code is the resolvedContainer (either the one provided by the developer or the default body). If you inspect in https://mui.com/material-ui/react-modal/#server-side-modal, you will see overflow: hidden is applied on Box container.

You are right that the disablePortal={true} is the same as container. The container prop only has an effect when disablePortal={false}. When disablePortal={true}, the container prop is ignored since no portal is created. But I think it's about the scroll lock style getting applied on the container. What if a developer has a custom container and no disablePortal?
I think we will have to revert a few commits here till this comment.

@Gr3q
Copy link
Author

Gr3q commented Nov 18, 2024

@ZeeshanTamboli I'm not sure I have a good answer to that.
My assumption is that disableScrollLock is made to prevent scrolling on the page when a modal is open, not on the immediate (parent) container, because that doesn't really have the desired effect (doesn't actually prevent scrolling). Maybe my assumption is wrong.
We made that change because it was not behaving as desired for disablePortal. I treat disablePortal and container the same, because what you can do with disablePortal={true} you can do it directly with container. For example in the server-side example if you remove setting container it will display exactly the same as with container.
If they are meant to be different I'm not sure what is the best course of action, container prop has nothing in it's docstring indicating that it will affect scroll locking behaviour in any way.
In short I can't come up with have any use-cases where the behaviour you are describing is useful by myself. If can provide me one or two I can start implementing something that matches those use cases too

As per the current production code, the prevention of scrolling is on the container, where the scrollContainer in the code is the resolvedContainer (either the one provided by the developer or the default body). If you inspect in https://mui.com/material-ui/react-modal/#server-side-modal, you will see overflow: hidden is applied on Box container.

You are right that the disablePortal={true} is the same as container. The container prop only has an effect when disablePortal={false}. When disablePortal={true}, the container prop is ignored since no portal is created. But I think it's about the scroll lock style getting applied on the container. What if a developer has a custom container and no disablePortal? I think we will have to revert a few commits here till this comment.

If you want to preserve that behaviour I agree, as long as disablePortal will work the same as now in this PR.

You did most of the changes there, would you like me to change it now or would you like to do it?

@ZeeshanTamboli
Copy link
Member

You did most of the changes there, would you like me to change it now or would you like to do it?

Please look into it. I don't have the bandwidth for it.

@Gr3q
Copy link
Author

Gr3q commented Dec 19, 2024

As per the current production code, the prevention of scrolling is on the container, where the scrollContainer in the code is the resolvedContainer (either the one provided by the developer or the default body). If you inspect in https://mui.com/material-ui/react-modal/#server-side-modal, you will see overflow: hidden is applied on Box container.

You are right that the disablePortal={true} is the same as container. The container prop only has an effect when disablePortal={false}. When disablePortal={true}, the container prop is ignored since no portal is created. But I think it's about the scroll lock style getting applied on the container. What if a developer has a custom container and no disablePortal?
I think we will have to revert a few commits here till #43318 (comment).

I'm changing the code, but I want to summarize the bugs this PR had, then what you want.

Bugs:

  1. scrollLock doesn't work for disablePortal on the docs page - I found out the correct behaviour is to lock the body element (just like before) because that's the only way scroll locking works. When container is provided locking was applied to the container (and that didn't work, this is the bevaiour on the main branch)
  2. Because I changed (fixed) the scroll locking behaviour to always apply to body, the whole modal doc page got locked. This was due to the server-side modal always open with no disableScrollLock. disablePortal applied or not doesn't make a difference here.

So let me clarify what you want:

You want me to change it that scroll lock works peropely when:

  • disablePortal is set
  • disablePortal is not set

and it shouldn't work properly (aka scroll lock applies to the container that effectively does nothing to prevent page scrolling):

  • container is set, regardless if disablePortal is set or not. This means even though container is not used when disablePortal is true and the modal shouldn't be mounted to the container at all, you still want the scroll lock to apply to that container.

I want you to confirm this because this is the only way bugs 1 and 2 can be resolved on the Modal docs page at the same time. (Edit: I'm asking because I will need to implement some workarounds to handle that)

@ZeeshanTamboli
Copy link
Member

@Gr3q It's been a while, but from what I remember you're right—that's what's needed.

@Gr3q
Copy link
Author

Gr3q commented Jan 20, 2025

@ZeeshanTamboli I fixed the doc page, it does what you want (so no breaking changes).

Added a comment that technically the behaviour around the scrolllock+container is still a bug, hopefully someone will remove it when the next major version comes.

Fixed the existing tests and added new tests for this too.

So now the PR should be ready. 🤞

Copy link
Member

@ZeeshanTamboli ZeeshanTamboli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Gr3q This looks good to me. I left one comment.

I'm unsure if this will be considered since the focus is shifting to re-implementing Material UI with the new Base UI in the next major release or the one after. I appreciate the time you've invested in this, and I apologize in advance if it doesn’t move forward. However, it might still be possible to release this now or in the next major version. This will definitely need further review. @DiegoAndai, could you take a look?

packages/mui-material/src/Modal/useModal.ts Outdated Show resolved Hide resolved
@Gr3q
Copy link
Author

Gr3q commented Jan 28, 2025

@Gr3q This looks good to me. I left one comment.

I'm unsure if this will be considered since the focus is shifting to re-implementing Material UI with the new Base UI in the next major release or the one after. I appreciate the time you've invested in this, and I apologize in advance if it doesn’t move forward. However, it might still be possible to release this now or in the next major version. This will definitely need further review. @DiegoAndai, could you take a look?

I think it's still be a while that is released, so it's worth fixing the current version now.

@DiegoAndai DiegoAndai requested review from DiegoAndai and removed request for mnajdova January 28, 2025 18:03
@DiegoAndai
Copy link
Member

Hey @Gr3q and @ZeeshanTamboli. First of all, thanks for working on this 🙌🏼

I'm unsure if this will be considered since the focus is shifting to re-implementing Material UI with the new Base UI in the next major release or the one after.

While true, we can still accept and review improvements from the community regarding these topics. So, let's keep working on this. We can land it in v7, which will help many people (given the number of upvotes in the issue).

The first thing I would say is that we should change to using aria-modal. Would that be possible? That would remove the need to traverse the DOM by applying/removing aria-hidden, which would greatly simplify the implementation.

It will be a breaking change, but we're already on v7 on the master branch, so we should make it.

Let me know what you think.

@ZeeshanTamboli
Copy link
Member

ZeeshanTamboli commented Jan 29, 2025

The first thing I would say is that we should change to using aria-modal. Would that be possible? That would remove the need to traverse the DOM by applying/removing aria-hidden, which would greatly simplify the implementation.

See conversation from #43318 (review). That wasn't planned back then but maybe we can consider it now for a major release.

Copy link
Member

@DiegoAndai DiegoAndai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I checked the discussion on this PR and also reached out to the Base UI team.

In conclusion, we are not able to stop using aria-hidden as I suggested. So I would say we should move forward with this PR's intention: fix aria-hidden handling when disablePortal is true.

Here's my initial review.

By the way, thanks for working on this @Gr3q

const isPreviousElement = element === previousElement;

// We came from here
if (isPreviousElement) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If isPreviousElement is true, it means that we're on the modal or one of its ancestors, right?

Comment on lines +84 to +85
// If any ancestor has aria-hidden applied (e.g. by another modal), the current modal could become inaccessible.
// We remove aria-hidden from ancestors to ensure the current modal is accessible, even though this might not be ideal if aria-hidden wasn't added by another modal (For example, if a developer manually applied aria-hidden to hide certain content, removing it could lead to unintended accessibility issues.).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't do this:

If multiple modals are open, or the developer applied aria-hidden to one of the modal's ancestors, it's the developer responsibility to fix it.

This is not an acceptable side-effect:

[...] if a developer manually applied aria-hidden to hide certain content, removing it could lead to unintended accessibility issues.

Comment on lines +125 to +134
// Implement workaround according to
// https://github.com/mui/material-ui/pull/43318#issuecomment-2553509176
// Technically applying scrollLock to the container does nothing, but
// we preserve the original buggy behavior because fixing it would be
// a breaking change.
// Original behavior: Apply scroll lock to container if it's set,
// otherwise apply to the body element. Because disablePortal
// passes in the correct `containerInfo.container`, the easiest way to
// make it apply to the correct element is to do this.
const container = props.container ? containerInfo.container : document.body;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this related to scroll locking and not aria-hidden? If so, we should move it to a separate PR

Comment on lines +92 to +94
const resolvedContainer = disablePortal
? ((mountNodeRef.current ?? modalRef.current)?.parentElement ?? getDoc().body)
: getContainer(container) || getDoc().body;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If providing the container prop was required when using disablePortal, this could be simplified to

Suggested change
const resolvedContainer = disablePortal
? ((mountNodeRef.current ?? modalRef.current)?.parentElement ?? getDoc().body)
: getContainer(container) || getDoc().body;
const resolvedContainer = getContainer(container) || getDoc().body;

Right?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
accessibility a11y component: modal This is the name of the generic UI component, not the React module! package: material-ui Specific to @mui/material
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Modal] aria-hidden should not be applied on non-portal modals
5 participants