Files
growstuff/app/javascript/components/Modal.jsx
T
Daniel O'Connorandgoogle-labs-jules[bot] 8875e89345 Dismiss responsive offcanvas when opening auth dialogs (#5259) (#5260)
On iPad and tablets, the main navigation menu is rendered as a
responsive Bootstrap offcanvas drawer (`#navbarSupportedContent.offcanvas-lg`).
When users tapped 'Sign in' in the drawer, the auth modal opened while the
offcanvas remained open in the DOM, allowing Bootstrap's active FocusTrap
on the offcanvas to intercept focusin events on document and steal focus
away from the modal inputs, preventing clicking or typing on iPadOS.

Update `closeOpenOffcanvases` in `Modal.jsx` and `RecordDialog.jsx` to
match all open offcanvases including responsive ones
(`.offcanvas.show, [class*="offcanvas-"].show`) and closest offcanvas
containers, invoke it when auth dialog triggers are clicked in
`AuthDialog.jsx`, restore focus correctly when the modal closes, and add
accompanying tests.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-11-03 04:10:38 +10:30

102 lines
3.7 KiB
React

import React, {useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])';
export function closeOpenOffcanvases(trigger) {
const container = trigger?.closest?.('.offcanvas, [class*="offcanvas-"]');
if (container) {
const toggle = document.querySelector(`[data-bs-target="#${container.id}"], [href="#${container.id}"]`);
if (toggle && toggle.focus) toggle.focus();
}
const openOffcanvases = Array.from(document.querySelectorAll('.offcanvas.show, [class*="offcanvas-"].show'));
const targets = new Set(container ? [container, ...openOffcanvases] : openOffcanvases);
targets.forEach((el) => {
if (window.bootstrap?.Offcanvas) {
const instance = window.bootstrap.Offcanvas.getInstance(el) || window.bootstrap.Offcanvas.getOrCreateInstance(el);
instance.hide();
} else {
el.classList.remove('show');
}
});
}
// A modal dialog using Bootstrap's modal styles, controlled by React (so it
// doesn't need Bootstrap's modal JavaScript). Focus moves into the dialog and
// is kept there, Escape and a click on the backdrop close it, and focus goes
// back to where it was when it closes. Rendered into document.body, so it
// sits in front of the page whatever stacking context its caller is in (the
// sticky sidebar on the garden layout page, for one).
export default function Modal({title, titleId, onClose, size = 'lg', children}) {
const contentRef = useRef(null);
useEffect(() => {
closeOpenOffcanvases();
const previouslyFocused = document.activeElement;
document.body.classList.add('modal-open');
const first = contentRef.current.querySelector('input, select, textarea');
(first || contentRef.current).focus();
return () => {
document.body.classList.remove('modal-open');
if (previouslyFocused && previouslyFocused.focus) {
const offcanvas = previouslyFocused.closest?.('.offcanvas, [class*="offcanvas-"]');
if (offcanvas && !offcanvas.classList.contains('show')) {
const trigger = document.querySelector(`[data-bs-target="#${offcanvas.id}"], [href="#${offcanvas.id}"]`);
if (trigger && trigger.focus) {
trigger.focus();
return;
}
}
previouslyFocused.focus();
}
};
}, []);
function onKeyDown(event) {
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Tab') {
const focusable = contentRef.current.querySelectorAll(FOCUSABLE);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
return createPortal(
<>
<div
className="modal fade show d-block"
tabIndex="-1"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
onKeyDown={onKeyDown}
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
>
<div className={size ? `modal-dialog modal-${size}` : 'modal-dialog'}>
<div className="modal-content" ref={contentRef} tabIndex="-1">
<div className="modal-header">
<h2 className="modal-title h5" id={titleId}>{title}</h2>
<button type="button" className="btn-close" aria-label="Close" onClick={onClose} />
</div>
{children}
</div>
</div>
</div>
<div className="modal-backdrop fade show" />
</>,
document.body
);
}