From 148282bab4b3e382b36ca13a18352b3333fc1743 Mon Sep 17 00:00:00 2001 From: Einar Date: Wed, 9 Sep 2026 15:17:16 +0200 Subject: [PATCH] Keep the non-modal ChatSidebar out of the background's way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Aria's Modal always intercepts its backdrop — its dismissal props only gate how it closes, never whether it blocks — so the default non-modal sidebar locked the whole page behind it even though the documented promise is that the background stays visible and interactive. Render a plain portaled layer for modal={false}: same classes and data attributes, no dialog semantics, no dismissal behavior at all, only the close/back affordances dismiss it. The modal path is unchanged. Add specs asserting a non-modal open sidebar leaves the background in the accessibility tree and clickable, and that the modal one still hides it. --- Source/Chat/ChatSidebar.tsx | 39 +++++- .../when_the_sidebar_is_open.ts | 119 ++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 Source/Chat/for_ChatSidebar/when_the_sidebar_is_open.ts diff --git a/Source/Chat/ChatSidebar.tsx b/Source/Chat/ChatSidebar.tsx index cf169d2a..129fdafd 100644 --- a/Source/Chat/ChatSidebar.tsx +++ b/Source/Chat/ChatSidebar.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import type { ButtonHTMLAttributes, HTMLAttributes } from 'react'; +import { createPortal } from 'react-dom'; import { Modal, ModalOverlay } from 'react-aria-components'; import type { ChatConversationLabels, ChatConversationProps } from './ChatConversation'; import { ChatConversation } from './ChatConversation'; @@ -384,7 +385,7 @@ export const ChatSidebar = < ); - return ( + return modal ? ( + ) : ( + // Non-modal: a chat lives *next to* the work, so the background must stay visible and + // interactive. React Aria's Modal blocks its backdrop by design (its dismissal props only + // gate how it closes, never whether it intercepts), so the default is a plain portaled + // layer with no dismissal behavior at all — only the close/back affordances dismiss it. + open && + createPortal( +
+
+ {panel} +
+
, + document.body, + ) ); }; diff --git a/Source/Chat/for_ChatSidebar/when_the_sidebar_is_open.ts b/Source/Chat/for_ChatSidebar/when_the_sidebar_is_open.ts new file mode 100644 index 00000000..1eb0969e --- /dev/null +++ b/Source/Chat/for_ChatSidebar/when_the_sidebar_is_open.ts @@ -0,0 +1,119 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { createElement } from 'react'; +import { ChatAuthorKind } from '../Kit/ChatAuthorKind'; +import { ChatSidebar } from '../ChatSidebar'; +import type { ChatTopic } from '../ChatTopic'; +import { + click, + render, + unmount, + type ChatSidebarInTheDom, +} from './given/a_chat_sidebar_in_the_dom'; + +const planning: ChatTopic = { + id: 'topic-1', + name: 'Sprint planning', + lastActivity: new Date('2026-08-27T10:00:00Z'), +}; + +const backgroundButton = () => + document.querySelector('[data-test="background"]')!; + +describe('when the sidebar is open', () => { + let backgroundClicks = 0; + let sidebar: ChatSidebarInTheDom; + + const renderWith = async (props: Record) => { + backgroundClicks = 0; + return render( + createElement( + 'div', + null, + createElement( + 'button', + { + 'data-test': 'background', + onClick: () => { + backgroundClicks += 1; + }, + }, + 'Background control', + ), + createElement(ChatSidebar, { + open: true, + onClose: () => {}, + topics: [planning], + messages: [], + onSendMessage: () => {}, + authorOf: () => ({ + name: 'Sample User', + kind: ChatAuthorKind.User, + }), + ...props, + }), + ), + ); + }; + + afterEach(async () => { + await unmount(sidebar); + }); + + describe('and it is not modal', () => { + beforeEach(async () => { + sidebar = await renderWith({}); + }); + + it('should leave the background in the accessibility tree', () => { + backgroundButton().hasAttribute('aria-hidden').should.be.false; + }); + + it('should not present itself as a dialog', () => { + (document.querySelector('[role="dialog"]') === null).should.be.true; + (document.querySelector('[aria-modal]') === null).should.be.true; + }); + + it('should still expose the open state on the sidebar frame', () => { + document + .querySelector('[data-cratis-part="backdrop"]')! + .getAttribute('data-modal')! + .should.equal('false'); + document + .querySelector('[data-cratis-part="backdrop"]')! + .getAttribute('data-open')! + .should.equal('true'); + document + .querySelector('[data-cratis-part="root"]')! + .getAttribute('data-open')! + .should.equal('true'); + }); + + it('should let the background stay interactive', async () => { + await click(backgroundButton()); + backgroundClicks.should.equal(1); + }); + }); + + describe('and it is modal', () => { + beforeEach(async () => { + sidebar = await renderWith({ modal: true }); + }); + + it('should hide the background from the accessibility tree', () => { + ( + backgroundButton().closest('[aria-hidden="true"]') !== null + ).should.be.true; + }); + + it('should mount the modal overlay frame', () => { + ( + document.querySelector('[data-cratis-part="backdrop"]')! + .hasAttribute('data-rac') + ).should.be.true; + }); + }); +});