Skip to content

feat: allow to stack modals on top of each other#3203

Merged
MartinCupela merged 6 commits into
masterfrom
feat/stackable-modals
Jun 3, 2026
Merged

feat: allow to stack modals on top of each other#3203
MartinCupela merged 6 commits into
masterfrom
feat/stackable-modals

Conversation

@MartinCupela
Copy link
Copy Markdown
Contributor

@MartinCupela MartinCupela commented Jun 3, 2026

🎯 Goal

The SDK needs to be able to display modals above each other with new ChannelDetails component. ChannelDetails is rendered in modal and contains buttons that invoke confirmation dialogs on top, e.g. "Delete chat". This PR adds possibility to have multiple open dialogs / modals at the same time.

Summary by CodeRabbit

  • New Features

    • Stacked dialogs with topmost tracking for correct overlay, focus, and close behavior
    • Optional dialog identifier to scope modal instances for layered modals
  • Bug Fixes

    • Only the topmost dialog/modal responds to close actions and Escape
    • Alert description spacing normalized; dialog accessibility attributes reliably wired
  • Tests

    • Expanded coverage for dialog stacking, open/close counts, topmost state, and accessibility assertions
    • Improved message rendering and accessibility test assertions

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 3, 2026

Size Change: +392 B (+0.06%)

Total Size: 656 kB

📦 View Changed
Filename Size Change
dist/cjs/emojis.js 2.54 kB -1 B (-0.04%)
dist/cjs/index.js 255 kB +98 B (+0.04%)
dist/cjs/ReactPlayerWrapper.js 546 B -1 B (-0.18%)
dist/cjs/useNotificationApi.js 49.8 kB +101 B (+0.2%)
dist/css/index.css 39.7 kB +3 B (+0.01%)
dist/es/index.mjs 251 kB +93 B (+0.04%)
dist/es/useNotificationApi.mjs 48.5 kB +99 B (+0.2%)
ℹ️ View Unchanged
Filename Size
dist/cjs/audioProcessing.js 1.74 kB
dist/cjs/mp3-encoder.js 814 B
dist/css/emoji-picker.css 178 B
dist/css/emoji-replacement.css 456 B
dist/es/audioProcessing.mjs 1.65 kB
dist/es/emojis.mjs 2.47 kB
dist/es/mp3-encoder.mjs 768 B
dist/es/ReactPlayerWrapper.mjs 485 B

compressed-size-action

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05e55567-a398-423a-83da-447620fdb7dd

📥 Commits

Reviewing files that changed from the base of the PR and between 58c45de and 17ff97c.

📒 Files selected for processing (1)
  • src/components/Message/__tests__/MessageText.test.tsx

📝 Walkthrough

Walkthrough

Adds ordered opened-dialog tracking and topmost selectors; scopes GlobalModal by per-instance dialog id and gates close/overlay/Escape to the topmost dialog; updates tests and accessibility wiring to validate opened-id order, topmost isolation, and derived aria ids.

Changes

Stacked Modal & Dialog Topmost State

Layer / File(s) Summary
Dialog Stack State & Derived Count
src/components/Dialog/service/DialogManager.ts
DialogManagerState now includes openedDialogIds: DialogId[]. State init, openDialogCount derivation, and lifecycle methods (open/close/remove) maintain openedDialogIds to track open-order.
Dialog Topmost State Selectors
src/components/Dialog/hooks/useDialog.ts
useModalDialog and useModalDialogIsOpen accept an optional id. New useDialogIsTopmost and useModalDialogIsTopmost determine whether an id is the last entry in openedDialogIds. useOpenedDialogCount now uses openedDialogIds.length.
Stacked GlobalModal Implementation
src/components/Modal/GlobalModal.tsx
GlobalModal accepts optional dialogId (stable generated id when omitted), resolves id-scoped dialog hooks, gates overlay/close/Escape handlers to topmost-only, uses resolvedDialogId for the portal, and conditions FocusScope/aria-modal/inert on topmost state.
Dialog & Modal Stack Tests
src/components/Dialog/__tests__/DialogsManager.test.ts, src/components/Dialog/__tests__/DialogManagerContext.test.tsx, src/components/Modal/__tests__/GlobalModal.test.tsx
DialogsManager tests assert openedDialogIds order, close/closeRest/remove updates and openDialogCount. DialogManagerContext tests add topmost assertions and a two-dialog stacked case. GlobalModal tests add renderStackedModals, update aria id expectations to use dialogId-derived ids, and verify Escape closes only the topmost modal.
Accessibility & Test Quality Updates
src/components/MessageActions/__tests__/MessageActions.test.tsx, src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx, src/components/Message/__tests__/MessageText.test.tsx, src/components/Dialog/styling/Alert.scss
Tests now derive referenced label/description ids from aria-labelledby/aria-describedby instead of hardcoded ids; MessageText tests use DOM queries instead of snapshots; Alert.scss sets .str-chat__alert-header__description { margin: 0; }; translation test helper typed.

Sequence Diagram

sequenceDiagram
  participant User
  participant ChildModal as GlobalModal(child)
  participant DialogMgr as DialogManager
  participant ParentModal as GlobalModal(parent)
  User->>ChildModal: overlay / Escape
  ChildModal->>DialogMgr: isTopmost?(childId)
  DialogMgr-->>ChildModal: true/false (based on openedDialogIds)
  ChildModal->>DialogMgr: close(childId) (when topmost)
  DialogMgr->>DialogMgr: remove childId from openedDialogIds
  DialogMgr-->>ParentModal: isTopmost?(parentId)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Stacked modals hop in tidy rows,
Topmost speaks and only that one goes,
Ids are tracked to keep the order tight,
Accessibility tuned and tests in sight,
A rabbit cheers: the stack is just right.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description includes the Goal section with clear motivation and use case, but the Implementation details and UI Changes sections specified in the template are missing. Add Implementation details section explaining the technical approach (e.g., openedDialogIds array tracking, topmost dialog detection) and UI Changes section with relevant screenshots or examples of stacked modals.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature being implemented: enabling stacked modals. It directly aligns with the PR's primary objective of allowing dialogs/modals to be displayed on top of each other.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/stackable-modals

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.85%. Comparing base (0433090) to head (17ff97c).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #3203      +/-   ##
==========================================
+ Coverage   83.81%   83.85%   +0.04%     
==========================================
  Files         439      439              
  Lines       13192    13203      +11     
  Branches     4274     4280       +6     
==========================================
+ Hits        11057    11072      +15     
+ Misses       2135     2131       -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/Modal/GlobalModal.tsx (1)

152-165: ⚠️ Potential issue | 🟠 Major | ⚖️ Poor tradeoff

Gate non-topmost modal from the accessibility tree when stacking dialogs

  • FocusScope’s contain={isTopmost} traps keyboard focus, but it doesn’t hide the non-topmost dialog content from screen readers.
  • GlobalModal always renders the dialog surface with aria-modal='true' (around src/components/Modal/GlobalModal.tsx:152-165) even when !isTopmost, and there’s no existing inert/aria-hidden handling for stacked modals elsewhere in the dialog/modal implementation.
  • When !isTopmost, apply inert (or aria-hidden) to the background modal layer/surface and ensure only the topmost modal has modal semantics (e.g., remove/disable aria-modal on background dialogs).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Modal/GlobalModal.tsx` around lines 152 - 165, GlobalModal
currently renders every stacked dialog surface with aria-modal="true" and only
uses FocusScope(contain={isTopmost}) to trap focus; to fix, when rendering the
dialog div inside FocusScope (symbols: FocusScope, isTopmost, GlobalModal,
handleDialogKeyDown, role), ensure only the topmost modal has aria-modal and
that non-topmost dialogs are gated from assistive tech by adding inert or
aria-hidden (e.g., set aria-modal={isTopmost ? 'true' : undefined} and add inert
or aria-hidden={isTopmost ? undefined : true} to the dialog container); keep
existing keyboard handlers and tabIndex behavior unchanged so only the topmost
modal retains modal semantics and screen-reader visibility.
🧹 Nitpick comments (1)
src/components/Modal/__tests__/GlobalModal.test.tsx (1)

19-42: 💤 Low value

Nit: renderStackedModals references ModalContent before its declaration (Line 45).

It's safe in practice because the helper body only executes inside it(...) callbacks (after module evaluation), so no temporal-dead-zone error occurs. Moving ModalContent above this helper would make the ordering self-evident.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Modal/__tests__/GlobalModal.test.tsx` around lines 19 - 42,
renderStackedModals references ModalContent before its declaration; move the
ModalContent component declaration above renderStackedModals so the helper
doesn't rely on a later-declared symbol. Update the test file so ModalContent is
declared prior to the renderStackedModals function (referencing ModalContent and
renderStackedModals) to make the dependency order explicit and avoid potential
TDZ confusion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/Message/__tests__/MessageText.test.tsx`:
- Around line 418-445: The tests in MessageText.test.tsx ("should render with a
custom wrapper class when one is set", "should render with a custom inner class
when one is set", and "should render with custom theme identifier in generated
css classes when theme is set") only assert visible text and thus don't verify
the actual class/theme behavior; update each test that calls renderMessageText
(with customProps: { customWrapperClass }, { customInnerClass }, and { theme:
'custom' }) to assert the DOM contains the expected class names or theme-based
CSS selectors (e.g., query the element returned by renderMessageText/getByText
and check classList contains the custom class or that a generated class includes
the "custom" identifier) or replace the assertion with a targeted snapshot
asserting those class selectors; use the existing helper renderMessageText and
generated message from generateMessage to locate the element to assert against.

---

Outside diff comments:
In `@src/components/Modal/GlobalModal.tsx`:
- Around line 152-165: GlobalModal currently renders every stacked dialog
surface with aria-modal="true" and only uses FocusScope(contain={isTopmost}) to
trap focus; to fix, when rendering the dialog div inside FocusScope (symbols:
FocusScope, isTopmost, GlobalModal, handleDialogKeyDown, role), ensure only the
topmost modal has aria-modal and that non-topmost dialogs are gated from
assistive tech by adding inert or aria-hidden (e.g., set aria-modal={isTopmost ?
'true' : undefined} and add inert or aria-hidden={isTopmost ? undefined : true}
to the dialog container); keep existing keyboard handlers and tabIndex behavior
unchanged so only the topmost modal retains modal semantics and screen-reader
visibility.

---

Nitpick comments:
In `@src/components/Modal/__tests__/GlobalModal.test.tsx`:
- Around line 19-42: renderStackedModals references ModalContent before its
declaration; move the ModalContent component declaration above
renderStackedModals so the helper doesn't rely on a later-declared symbol.
Update the test file so ModalContent is declared prior to the
renderStackedModals function (referencing ModalContent and renderStackedModals)
to make the dependency order explicit and avoid potential TDZ confusion.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 566f0b54-5638-45b8-b0fb-4608b7b75e1f

📥 Commits

Reviewing files that changed from the base of the PR and between 4cddb02 and c5ea8f8.

⛔ Files ignored due to path filters (1)
  • src/components/Message/__tests__/__snapshots__/MessageText.test.tsx.snap is excluded by !**/*.snap
📒 Files selected for processing (10)
  • src/components/Dialog/__tests__/DialogManagerContext.test.tsx
  • src/components/Dialog/__tests__/DialogsManager.test.ts
  • src/components/Dialog/hooks/useDialog.ts
  • src/components/Dialog/service/DialogManager.ts
  • src/components/Dialog/styling/Alert.scss
  • src/components/Message/__tests__/MessageText.test.tsx
  • src/components/MessageActions/__tests__/MessageActions.test.tsx
  • src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx
  • src/components/Modal/GlobalModal.tsx
  • src/components/Modal/__tests__/GlobalModal.test.tsx

Comment thread src/components/Message/__tests__/MessageText.test.tsx
# Conflicts:
#	src/components/Modal/__tests__/GlobalModal.test.tsx
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
src/components/Message/__tests__/MessageText.test.tsx (2)

422-432: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix: Pass customWrapperClass directly in customProps, not wrapped in a Message component.

The current approach passes a Message component function in customProps, but this component is never invoked—it's just passed as an unused prop to the <Message> component (line 122 in renderMessageText). As a result, customWrapperClass never reaches the <MessageText> component (line 123), and the assertion will fail.

The renderMessageText helper spreads customProps into both <Message> and <MessageText>:

<Message {...customProps}>
  <MessageText {...customProps} />
</Message>

So to pass customWrapperClass to MessageText, include it directly in customProps.

🔧 Proposed fix
 it('should render with a custom wrapper class when one is set', async () => {
   const customWrapperClass = 'custom-wrapper';
   const message = generateMessage({ text: 'hello world' });
   const { getByText } = await renderMessageText({
     customProps: {
+      customWrapperClass,
       message,
-      Message: () => (
-        <MessageText customWrapperClass={customWrapperClass} message={message} />
-      ),
     },
   });

   expect(getByText('hello world').closest(`.${customWrapperClass}`)).toHaveClass(
     customWrapperClass,
   );
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Message/__tests__/MessageText.test.tsx` around lines 422 -
432, The test is passing customWrapperClass inside a fake Message component so
it never reaches MessageText; update the test to include customWrapperClass
directly in the customProps object passed to renderMessageText (so that
renderMessageText spreads it into both <Message> and <MessageText>), i.e.,
remove the Message: () => (...) wrapper and put customWrapperClass:
customWrapperClass at the top level of customProps so MessageText receives it
and the assertion can find the wrapper class.

438-447: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Fix: Pass customInnerClass directly in customProps, not wrapped in a Message component.

Same issue as the previous test—the Message component in customProps is never invoked, so customInnerClass doesn't reach <MessageText>.

🔧 Proposed fix
 it('should render with a custom inner class when one is set', async () => {
   const customInnerClass = 'custom-inner';
   const message = generateMessage({ text: 'hi mate' });
   const { getByTestId } = await renderMessageText({
     customProps: {
+      customInnerClass,
       message,
-      Message: () => (
-        <MessageText customInnerClass={customInnerClass} message={message} />
-      ),
     },
   });

   expect(getByTestId(messageTextTestId)).toHaveClass(customInnerClass);
 });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/Message/__tests__/MessageText.test.tsx` around lines 438 -
447, The test is passing customInnerClass incorrectly by wrapping MessageText in
a Message component so it never gets invoked; change the call to
renderMessageText to include customInnerClass directly in customProps (e.g.
customProps: { message, customInnerClass, MessageText } or simply customProps: {
message, customInnerClass } depending on renderMessageText signature) so that
MessageText receives the prop, then assert with getByTestId(messageTextTestId)
toHaveClass(customInnerClass); update references to renderMessageText,
customProps, Message, MessageText, customInnerClass and messageTextTestId
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/components/Message/__tests__/MessageText.test.tsx`:
- Around line 422-432: The test is passing customWrapperClass inside a fake
Message component so it never reaches MessageText; update the test to include
customWrapperClass directly in the customProps object passed to
renderMessageText (so that renderMessageText spreads it into both <Message> and
<MessageText>), i.e., remove the Message: () => (...) wrapper and put
customWrapperClass: customWrapperClass at the top level of customProps so
MessageText receives it and the assertion can find the wrapper class.
- Around line 438-447: The test is passing customInnerClass incorrectly by
wrapping MessageText in a Message component so it never gets invoked; change the
call to renderMessageText to include customInnerClass directly in customProps
(e.g. customProps: { message, customInnerClass, MessageText } or simply
customProps: { message, customInnerClass } depending on renderMessageText
signature) so that MessageText receives the prop, then assert with
getByTestId(messageTextTestId) toHaveClass(customInnerClass); update references
to renderMessageText, customProps, Message, MessageText, customInnerClass and
messageTextTestId accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e76d3be-e466-409f-94b5-ce0c5e9c4b97

📥 Commits

Reviewing files that changed from the base of the PR and between c5ea8f8 and f811b40.

📒 Files selected for processing (3)
  • src/components/Message/__tests__/MessageText.test.tsx
  • src/components/Modal/GlobalModal.tsx
  • src/components/Modal/__tests__/GlobalModal.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/components/Modal/tests/GlobalModal.test.tsx
  • src/components/Modal/GlobalModal.tsx

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants