Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1755,10 +1755,11 @@ textarea.input.composer__textarea {
}
}

/* PortalDropdown moves the open menu to <body>, where the DS rule that reveals
it (.dropdown-container.open .dropdown-menu) no longer matches, and where it
is positioned from the viewport. The z-index must clear .modal-overlay
(10000) for menus opened from inside a modal. */
/* PortalDropdown moves the open menu out of the dropdown container (into the
modal overlay when present), where the DS rule that reveals it
(.dropdown-container.open .dropdown-menu) no longer matches, and where it is
positioned from the viewport. The z-index must clear .modal-dialog content
for menus opened from inside a modal. */
.dropdown-menu.dropdown-menu--portaled {
display: block;
position: fixed;
Expand Down
39 changes: 29 additions & 10 deletions public/portal-dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,21 @@ const MENU_GAP = 4;
let menuIdCounter = 0;

/**
* Dropdown that renders its open menu in <body> instead of inside its own
* container.
* Dropdown that relocates its open menu out of the clipping container.
*
* The design-system Dropdown draws the menu as a child of the container, so any
* ancestor that clips overflow cuts the menu off — the settings modal body
* scrolls, so it does exactly that. Unclipping those ancestors is not a usable
* workaround: it also frees tall fields lower in the form to paint over the
* modal footer. Moving just the menu out sidesteps both.
*
* Portal host (D7): prefer the nearest `.modal-overlay` over `document.body`.
* After the modal marks non-overlay body children `inert`, a body-portaled menu
* is removed from the accessibility / interaction tree while `aria-modal` also
* hides it from AT. Mounting as a sibling of `.modal-dialog` keeps the menu
* inside the dialog subtree (and out of `inert`) while still escaping the
* scrolling `.modal-content` clip.
*
* Extra options on top of the base component:
* matchToggleWidth — size the menu to its toggle rather than the stylesheet's
* fixed width.
Expand Down Expand Up @@ -49,6 +55,11 @@ export default class PortalDropdown extends Dropdown {
document.addEventListener('click', this._onCaptureClick, true);
}

/** Prefer the open modal overlay so the menu stays in the aria-modal tree. */
getPortalRoot() {
return this.container?.closest('.modal-overlay') || document.body;
}

updateToggleState() {
super.updateToggleState();
if (this.isOpen) {
Expand All @@ -64,10 +75,13 @@ export default class PortalDropdown extends Dropdown {
this._placeholder.hidden = true;
}

if (this.menu.parentElement !== document.body) {
const root = this.getPortalRoot();
this._portalRoot = root;

if (this.menu.parentElement !== root) {
const width = this.measureMenuWidth();
this.container.insertBefore(this._placeholder, this.menu);
document.body.appendChild(this.menu);
root.appendChild(this.menu);
if (width) this.menu.style.width = `${width}px`;
}

Expand Down Expand Up @@ -106,32 +120,35 @@ export default class PortalDropdown extends Dropdown {
if (!this.config.growToFit) this.menu.style.width = '';
}

if (this._placeholder?.parentElement && this.menu?.parentElement === document.body) {
const root = this._portalRoot;
if (this._placeholder?.parentElement && this.menu && root && this.menu.parentElement === root) {
this._placeholder.parentElement.insertBefore(this.menu, this._placeholder);
this._placeholder.remove();
}
this._portalRoot = null;
}

/**
* Width has to be read while the menu is still in the container: the
* stylesheet sizes it with `width: 100%` on narrow viewports, which would
* resolve against <body> once moved.
* resolve against the portal root once moved.
*/
measureMenuWidth() {
return this.config.matchToggleWidth ? this.toggle.offsetWidth : this.menu.offsetWidth;
}

remeasureMenuWidth() {
if (!this._placeholder?.parentElement) return;
if (this.menu.parentElement !== document.body) return;
const root = this._portalRoot;
if (!root || this.menu.parentElement !== root) return;

const parent = this._placeholder.parentElement;
parent.insertBefore(this.menu, this._placeholder);
this.menu.classList.remove('dropdown-menu--portaled');
this.menu.style.width = '';
const width = this.measureMenuWidth();

document.body.appendChild(this.menu);
root.appendChild(this.menu);
this.menu.classList.add('dropdown-menu--portaled');
if (width) this.menu.style.width = `${width}px`;
}
Expand Down Expand Up @@ -163,8 +180,10 @@ export default class PortalDropdown extends Dropdown {

destroy() {
this.unmountMenu();
// The menu is still in <body> if it was never returned to the container.
if (this.menu?.parentElement === document.body) this.menu.remove();
// Still outside the container if unmount could not restore it (e.g. host gone).
if (this.menu?.parentElement && this.menu.parentElement !== this.container) {
this.menu.remove();
}

if (this._onCaptureClick) {
document.removeEventListener('click', this._onCaptureClick, true);
Expand Down
52 changes: 52 additions & 0 deletions tests/dom/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,55 @@ describe('settings labels a11y (D6)', () => {
expect(menu?.getAttribute('aria-describedby')).toBe('thinkingDesc');
});
});

describe('settings thinking menu a11y (D7)', () => {
async function openSettings() {
await bootApp();
q('#settingsBtn').click();
await settle();
}

it('keeps the open Thinking menu inside the aria-modal dialog and not inert', async () => {
await openSettings();

const overlay = q('.modal-overlay[aria-modal="true"]');
const toggle = q('#thinkingDropdownEl .dropdown-toggle');
expect(overlay).toBeTruthy();
expect(toggle).toBeTruthy();

toggle.click();
await settle();

const menu = document.querySelector('[id^="portal-dropdown-menu-"]');
expect(menu).toBeTruthy();
expect(menu.classList.contains('dropdown-menu--portaled')).toBe(true);
// Mounted on the overlay (sibling of .modal-dialog), not document.body.
expect(menu.parentElement).toBe(overlay);
expect(overlay.contains(menu)).toBe(true);
expect(menu.parentElement === document.body).toBe(false);
// Not under an inert subtree (body-portaled menus get inert via D1).
expect(menu.closest('[inert]')).toBeNull();
// Options remain interactive for keyboard / pointer.
const options = menu.querySelectorAll('.dropdown-menu-item');
expect(options.length).toBeGreaterThan(0);
expect([...options].every((el) => el.closest('[inert]') == null)).toBe(true);
});

it('can select a Thinking option while Settings stays open', async () => {
await openSettings();

const toggle = q('#thinkingDropdownEl .dropdown-toggle');
toggle.click();
await settle();

const menu = document.querySelector('[id^="portal-dropdown-menu-"]');
const high = menu.querySelector('.dropdown-menu-item[data-value="high"]');
expect(high).toBeTruthy();
high.click();
await settle();

expect(q('.modal-overlay.open')).toBeTruthy();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.textContent).toMatch(/High/i);
});
});