ui: only the top modal answers Escape, and the audited widths
Two things, both reported from the running app.
Nested modals could not exist. Mantine binds a window-level keydown listener
per open modal, gated only on whether that modal is open -- nothing checks which
one the user is looking at:
useWindowEvent('keydown', (event) => {
if (event.key === 'Escape' && closeOnEscape && ... && opened) onClose();
}, {capture: true});
Two open modals meant two listeners, so one Escape closed both. Reproduced on
the dashboard: the download modal plus the search modal over it, one press, both
gone, back to the page. Both also sat at z-index 200, so which one drew on top
was DOM order rather than which the user opened.
Mantine's own answer is Modal.Stack with a stackId, which is the only thing that
gates closeOnEscape. Not used, for two reasons: every call site would have to
be rewritten to hoist its modal into a stack, and it sets `__hidden` on
everything below the top -- the parent disappears while the child is open. The
parent should be dimmed and still there, as it was before the migration.
So the wrapper keeps the register itself: a module-level list of open modals,
most recent on top, from which each modal takes closeOnEscape, trapFocus and a
z-index two above its parent. Module-level rather than React context because
the two modals in the bug are not nested in the JSX at all -- the search modal
is rendered by KeyboardShortcutsProvider near the app root and the download
modal by the dashboard. They are siblings that happen to be open together, and
no context relates them. The increment is small on purpose: Mantine puts
popovers at 300, and a stack that climbed past it would paint over the select
dropdowns inside its own forms.
Widths: Roland audited the modal list and chose sizes against Mantine's scale.
Sixteen get wider -- twelve to `large` (620), four to `fullscreen` -- and eleven
keep the width they had, with the size now written down so the decision is in
the source rather than in a default. Flasher was passing Mantine's raw `sm`,
which worked but bypassed the naming the audit used; it says `tiny` now.
The size table itself is guarded, because it is the single thing that halved
every modal during the migration and a quiet edit to it moves seventy at once.
Semantic's widths, measured on the pre-migration build still running on the QA
Pi: mini 360, tiny 540, small 720, large 1080, no-size 900. Mantine's, read out
of its stylesheet: 320/380/440/620/780. A `small` modal went 720 -> 440 and one
with no size went 900 -> 440. Three cases now hold the scale, the mapping, and
the rule that no call site may name a size the table does not know -- an unknown
name is passed to Mantine, ignored, and silently rendered at the default.
Tested in a real browser: none of it is visible to jsdom -- the listener is on
window with capture, the stacking is z-index, and "still visible behind" is
geometry. Note that `should('be.visible')` is the wrong assertion for the
parent: it is an occlusion check, so a child drawn correctly over its parent
trips it. The test uses a wide viewport and asserts the parent is rendered and
shows to both sides of the child. Planting the pre-fix behaviour reddens three.
1124 jest / 62 suites, 8 in modal-stack.cy.js, clean npm run build. The 12
failures in Tags/Channels/Download/Inventory.cy.js are the known pre-existing
Semantic-era selector failures, unchanged.
2026-08-02 15:26:15 -06:00
|
|
|
import React from 'react';
|
|
|
|
|
import {Button, Modal} from './index';
|
|
|
|
|
import {openModalCount} from './Overlays';
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
* Nested modals: only the one on top answers the keyboard, and the one underneath stays on
|
|
|
|
|
* screen behind it.
|
|
|
|
|
*
|
|
|
|
|
* Mantine binds a window-level `keydown` listener per open modal, gated only on whether that
|
|
|
|
|
* modal is open -- nothing checks which one the user is looking at. Two open modals meant
|
|
|
|
|
* two listeners, so one Escape closed both: opening the search modal over the dashboard's
|
|
|
|
|
* download modal and pressing Escape once left you on the dashboard. Reproduced in the
|
|
|
|
|
* running app before this was written.
|
|
|
|
|
*
|
|
|
|
|
* A real browser, because none of it is visible to jsdom: the listener is on `window` with
|
|
|
|
|
* `capture: true`, the stacking is z-index, and "still visible behind" is geometry.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const Nested = ({onParentClose, onChildClose}) => {
|
|
|
|
|
const [parent, setParent] = React.useState(false);
|
|
|
|
|
const [child, setChild] = React.useState(false);
|
|
|
|
|
return <>
|
|
|
|
|
<Button data-testid='open-parent' onClick={() => setParent(true)}>Open parent</Button>
|
|
|
|
|
<Modal open={parent} size='large'
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setParent(false);
|
|
|
|
|
onParentClose?.();
|
|
|
|
|
}}>
|
|
|
|
|
<Modal.Header>Parent</Modal.Header>
|
|
|
|
|
<Modal.Content>
|
|
|
|
|
<p data-testid='parent-body'>The parent's own content.</p>
|
|
|
|
|
<Button data-testid='open-child' onClick={() => setChild(true)}>Open child</Button>
|
|
|
|
|
</Modal.Content>
|
|
|
|
|
</Modal>
|
|
|
|
|
{/*
|
|
|
|
|
* A SIBLING of the parent in the JSX, not a descendant. This is the shape the bug
|
|
|
|
|
* actually took -- the search modal is rendered by KeyboardShortcutsProvider near
|
|
|
|
|
* the app root while the download modal is rendered by the dashboard -- and it is
|
|
|
|
|
* why the register is module-level rather than a React context, which could not
|
|
|
|
|
* relate these two at all.
|
|
|
|
|
*/}
|
|
|
|
|
<Modal open={child} size='small'
|
|
|
|
|
onClose={() => {
|
|
|
|
|
setChild(false);
|
|
|
|
|
onChildClose?.();
|
|
|
|
|
}}>
|
|
|
|
|
<Modal.Header>Child</Modal.Header>
|
|
|
|
|
<Modal.Content><p data-testid='child-body'>The child's own content.</p></Modal.Content>
|
|
|
|
|
</Modal>
|
|
|
|
|
</>
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const openBoth = () => {
|
|
|
|
|
cy.mountUI(<Nested/>);
|
|
|
|
|
cy.get('[data-testid="open-parent"]').click();
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Parent').should('exist');
|
|
|
|
|
cy.get('[data-testid="open-child"]').click();
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Child').should('exist');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Escape has to come from a real element: Mantine's handler calls
|
|
|
|
|
// `event.target.getAttribute(...)`, which throws if the event was dispatched on `document`.
|
|
|
|
|
const pressEscape = () => cy.get('body').trigger('keydown', {key: 'Escape', bubbles: true});
|
|
|
|
|
|
|
|
|
|
describe('a modal opened over another', () => {
|
|
|
|
|
it('closes only itself on Escape, leaving the one underneath open', () => {
|
|
|
|
|
// The bug, exactly: one press used to take both.
|
|
|
|
|
openBoth();
|
|
|
|
|
|
|
|
|
|
pressEscape();
|
|
|
|
|
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Child').should('not.exist');
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Parent').should('exist');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('lets the next Escape close the one underneath', () => {
|
|
|
|
|
// The other half: suppressing Escape on the parent must be temporary, or the parent
|
|
|
|
|
// becomes unclosable by keyboard for the rest of its life.
|
|
|
|
|
openBoth();
|
|
|
|
|
|
|
|
|
|
pressEscape();
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Child').should('not.exist');
|
|
|
|
|
pressEscape();
|
|
|
|
|
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Parent').should('not.exist');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the parent on screen behind the child, not hidden', () => {
|
|
|
|
|
/*
|
|
|
|
|
* Mantine's own Modal.Stack sets `__hidden` on everything below the top, so the
|
|
|
|
|
* parent vanishes while the child is open. That is the behaviour this deliberately
|
|
|
|
|
* does not use -- the parent should be dimmed and still there.
|
|
|
|
|
*
|
|
|
|
|
* Not `should('be.visible')`: that is an occlusion check, and a child drawn over its
|
|
|
|
|
* parent trips it by definition -- at the default component viewport the 440px child
|
|
|
|
|
* covers the 620px parent completely and the assertion fails on correct behaviour.
|
|
|
|
|
* A wide viewport instead, where the parent is genuinely broader than the child, and
|
|
|
|
|
* the claim is stated as what it is: the parent is rendered, and some of it is not
|
|
|
|
|
* behind the child.
|
|
|
|
|
*/
|
|
|
|
|
cy.viewport(1200, 800);
|
|
|
|
|
openBoth();
|
|
|
|
|
|
|
|
|
|
cy.get('.mantine-Modal-content').should(($contents) => {
|
|
|
|
|
expect($contents, 'both modals are rendered').to.have.length(2);
|
|
|
|
|
|
|
|
|
|
const boxes = [...$contents].map(content => ({
|
|
|
|
|
el: content, box: content.getBoundingClientRect(),
|
|
|
|
|
display: getComputedStyle(content).display,
|
|
|
|
|
visibility: getComputedStyle(content).visibility,
|
|
|
|
|
}));
|
|
|
|
|
const [child, parent] = [...boxes].sort((a, b) => a.box.width - b.box.width);
|
|
|
|
|
|
|
|
|
|
// `__hidden` would show up as either of these.
|
|
|
|
|
expect(parent.display, 'the parent is not display:none').to.not.equal('none');
|
|
|
|
|
expect(parent.visibility, 'the parent is not hidden').to.equal('visible');
|
|
|
|
|
expect(parent.box.height, 'the parent still has a box').to.be.greaterThan(0);
|
|
|
|
|
|
|
|
|
|
// And it is wider than the child on both sides, so it is actually seen.
|
|
|
|
|
expect(parent.box.left, 'parent shows to the left of the child')
|
|
|
|
|
.to.be.lessThan(child.box.left - 1);
|
|
|
|
|
expect(parent.box.right, 'parent shows to the right of the child')
|
|
|
|
|
.to.be.greaterThan(child.box.right + 1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('draws the child above the parent', () => {
|
|
|
|
|
// Both modals used to sit at z-index 200, so which one won was DOM order rather than
|
|
|
|
|
// which one the user opened.
|
|
|
|
|
openBoth();
|
|
|
|
|
|
|
|
|
|
cy.get('.mantine-Modal-inner').should(($inners) => {
|
|
|
|
|
const zIndexes = [...$inners].map(inner =>
|
|
|
|
|
parseInt(getComputedStyle(inner).zIndex, 10));
|
|
|
|
|
|
|
|
|
|
expect(zIndexes, 'two modals are stacked').to.have.length(2);
|
|
|
|
|
expect(Math.max(...zIndexes), 'the child clears the parent')
|
|
|
|
|
.to.be.greaterThan(Math.min(...zIndexes));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('keeps the stack below the popovers that open inside it', () => {
|
|
|
|
|
/*
|
|
|
|
|
* A Select or date picker inside a modal renders at Mantine's popover z-index of 300.
|
|
|
|
|
* A stack that climbed past that would paint over the dropdowns in its own form,
|
|
|
|
|
* which is a worse bug than the one being fixed.
|
|
|
|
|
*/
|
|
|
|
|
openBoth();
|
|
|
|
|
|
|
|
|
|
cy.get('.mantine-Modal-inner').should(($inners) => {
|
|
|
|
|
const highest = Math.max(...[...$inners]
|
|
|
|
|
.map(inner => parseInt(getComputedStyle(inner).zIndex, 10)));
|
|
|
|
|
expect(highest, 'top modal stays under the popover layer').to.be.lessThan(300);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('the register of open modals', () => {
|
|
|
|
|
it('empties as they close', () => {
|
|
|
|
|
/*
|
|
|
|
|
* The failure mode of keeping a module-level list: a modal that unmounts without
|
|
|
|
|
* deregistering leaves a ghost on top forever, and then NOTHING closes on Escape --
|
|
|
|
|
* every real modal believes something else is above it. Silent, permanent, and
|
|
|
|
|
* invisible to a test that only ever opens one modal.
|
|
|
|
|
*/
|
|
|
|
|
openBoth();
|
|
|
|
|
cy.then(() => expect(openModalCount(), 'both registered').to.equal(2));
|
|
|
|
|
|
|
|
|
|
pressEscape();
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Child').should('not.exist');
|
|
|
|
|
cy.then(() => expect(openModalCount(), 'child deregistered').to.equal(1));
|
|
|
|
|
|
|
|
|
|
pressEscape();
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Parent').should('not.exist');
|
|
|
|
|
cy.then(() => expect(openModalCount(), 'register is empty').to.equal(0));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('is empty before anything opens, so the counts above mean something', () => {
|
|
|
|
|
cy.mountUI(<Nested/>);
|
|
|
|
|
cy.then(() => expect(openModalCount()).to.equal(0));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('a single modal', () => {
|
|
|
|
|
it('still closes on Escape', () => {
|
|
|
|
|
// The premise for every case above. Suppressing Escape on all but the top is
|
|
|
|
|
// worthless if the top does not respond either.
|
|
|
|
|
cy.mountUI(<Nested/>);
|
|
|
|
|
cy.get('[data-testid="open-parent"]').click();
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Parent').should('exist');
|
|
|
|
|
|
|
|
|
|
pressEscape();
|
|
|
|
|
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Parent').should('not.exist');
|
review: the spread was overwriting the stack, and the guard was blind
Acting on the review of #622. Two bugs, both real, and the second one is a
test of mine that could not fail for its stated reason.
The stack props were set BEFORE `{...props}`, so any call site passing
`closeOnEscape` overwrote the stack's decision and re-armed Mantine's
window-level Escape listener on a modal that is not on top -- reinstating the
bug the change exists to fix. Three call sites pass it: CollectionReorganizeModal
and BatchReorganizeModal disable Escape while a reorganize runs, Flasher while
it writes an image. The concrete breakage is the one place the app really does
stack two modals: ConflictResolutionModal opens as a sibling above the
reorganize modal, so the moment the reorganize finished and `closeOnEscape`
returned to true, one Escape closed both. They are now destructured out and
composed -- `isTop && closeOnEscape` -- because "only the top modal answers
Escape" and "do not dismiss me while I am busy" are both true. Mantine's own
Modal.Stack applies its stack props after the caller's for the same reason.
Two Cypress cases, one per direction; planting the old order reddens the first.
`Confirm` was still passing Mantine's raw `sm`, and the audit guard I added in
the same PR was supposed to catch exactly that. It did not, and the reason is
worse than the miss: the scanner matched `<Modal\b[^>]*?\bsize='...'`, and
`onClose={() => ...}` contains a `>`, so it stopped at the arrow and never saw
the rest of the tag. It read 44 of the 56 modals that declare a size; the
twelve it skipped included the single offender in the codebase, in this very
file. A guard that scans a fifth of nothing looks identical to a clean
codebase. It now walks the tag with a paren counter, accepts either quote
style, and asserts it scanned more than fifty tags first -- so a scanner that
silently matches nothing fails instead of passing. Confirm says `tiny`.
Also from the review: a bare `expect(titles()).to.not.include('Parent')` in the
Cypress spec ran during the synchronous phase, before any cy command executed
and while nothing was open, so it always passed and guarded nothing. Deleted
rather than repaired -- the retried assertion above it already makes the claim.
An unregistered-but-open modal reported `isTop` even with a parent registered,
which gave both the same z-index for the frame before the effect ran -- a visible
flash of the wrong modal in front. It is only top now if the stack is empty.
Nit: the comment claimed Mantine ignores unknown size names. It ignores unknown
SEMANTIC names and falls back to 440px; it recognises and honours its own. Two
different hazards, both silent, and the comment now says which is which.
1124 jest / 62 suites, 10 in modal-stack.cy.js, 196 in ui-layout.cy.js,
clean npm run build.
2026-08-02 15:39:43 -06:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('a parent that disables Escape while it is busy', () => {
|
|
|
|
|
/*
|
|
|
|
|
* Three call sites pass `closeOnEscape` of their own: CollectionReorganizeModal and
|
|
|
|
|
* BatchReorganizeModal disable it while a reorganize runs, Flasher while it writes an
|
|
|
|
|
* image. The stack props were set BEFORE the `{...props}` spread, so those call sites
|
|
|
|
|
* overwrote the stack's decision and re-armed Mantine's window-level listener on a modal
|
|
|
|
|
* that is not on top.
|
|
|
|
|
*
|
|
|
|
|
* The concrete breakage: ConflictResolutionModal opens as a sibling above the reorganize
|
|
|
|
|
* modal, so as soon as the reorganize finished and `closeOnEscape` went back to true, one
|
|
|
|
|
* Escape closed both again -- the original bug, in the one place the app really does
|
|
|
|
|
* stack two modals.
|
|
|
|
|
*
|
|
|
|
|
* "Only the top modal answers Escape" and "do not dismiss me while I am busy" are both
|
|
|
|
|
* true; they compose with AND.
|
|
|
|
|
*/
|
|
|
|
|
const Busy = ({busy}) => {
|
|
|
|
|
const [parent, setParent] = React.useState(true);
|
|
|
|
|
const [child, setChild] = React.useState(true);
|
|
|
|
|
return <>
|
|
|
|
|
<Modal open={parent} size='large' closeOnEscape={!busy}
|
|
|
|
|
onClose={() => setParent(false)}>
|
|
|
|
|
<Modal.Header>Reorganize</Modal.Header>
|
|
|
|
|
<Modal.Content><p>working</p></Modal.Content>
|
|
|
|
|
</Modal>
|
|
|
|
|
<Modal open={child} size='small' onClose={() => setChild(false)}>
|
|
|
|
|
<Modal.Header>Conflicts</Modal.Header>
|
|
|
|
|
<Modal.Content><p>resolve these</p></Modal.Content>
|
|
|
|
|
</Modal>
|
|
|
|
|
</>
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
it('still closes only the child when the parent has re-enabled Escape', () => {
|
|
|
|
|
cy.mountUI(<Busy busy={false}/>);
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Conflicts').should('exist');
|
|
|
|
|
|
|
|
|
|
pressEscape();
|
|
|
|
|
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Conflicts').should('not.exist');
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Reorganize').should('exist');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('honours the parent\'s own refusal once it is on top again', () => {
|
|
|
|
|
/*
|
|
|
|
|
* The other direction, and the reason the two are composed rather than the stack
|
|
|
|
|
* simply winning: a busy parent must stay put even when nothing is above it.
|
|
|
|
|
*/
|
|
|
|
|
cy.mountUI(<Busy busy/>);
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Conflicts').should('exist');
|
|
|
|
|
|
|
|
|
|
pressEscape();
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Conflicts').should('not.exist');
|
|
|
|
|
pressEscape();
|
|
|
|
|
|
|
|
|
|
cy.contains('.mantine-Modal-title', 'Reorganize')
|
|
|
|
|
.should('exist');
|
ui: only the top modal answers Escape, and the audited widths
Two things, both reported from the running app.
Nested modals could not exist. Mantine binds a window-level keydown listener
per open modal, gated only on whether that modal is open -- nothing checks which
one the user is looking at:
useWindowEvent('keydown', (event) => {
if (event.key === 'Escape' && closeOnEscape && ... && opened) onClose();
}, {capture: true});
Two open modals meant two listeners, so one Escape closed both. Reproduced on
the dashboard: the download modal plus the search modal over it, one press, both
gone, back to the page. Both also sat at z-index 200, so which one drew on top
was DOM order rather than which the user opened.
Mantine's own answer is Modal.Stack with a stackId, which is the only thing that
gates closeOnEscape. Not used, for two reasons: every call site would have to
be rewritten to hoist its modal into a stack, and it sets `__hidden` on
everything below the top -- the parent disappears while the child is open. The
parent should be dimmed and still there, as it was before the migration.
So the wrapper keeps the register itself: a module-level list of open modals,
most recent on top, from which each modal takes closeOnEscape, trapFocus and a
z-index two above its parent. Module-level rather than React context because
the two modals in the bug are not nested in the JSX at all -- the search modal
is rendered by KeyboardShortcutsProvider near the app root and the download
modal by the dashboard. They are siblings that happen to be open together, and
no context relates them. The increment is small on purpose: Mantine puts
popovers at 300, and a stack that climbed past it would paint over the select
dropdowns inside its own forms.
Widths: Roland audited the modal list and chose sizes against Mantine's scale.
Sixteen get wider -- twelve to `large` (620), four to `fullscreen` -- and eleven
keep the width they had, with the size now written down so the decision is in
the source rather than in a default. Flasher was passing Mantine's raw `sm`,
which worked but bypassed the naming the audit used; it says `tiny` now.
The size table itself is guarded, because it is the single thing that halved
every modal during the migration and a quiet edit to it moves seventy at once.
Semantic's widths, measured on the pre-migration build still running on the QA
Pi: mini 360, tiny 540, small 720, large 1080, no-size 900. Mantine's, read out
of its stylesheet: 320/380/440/620/780. A `small` modal went 720 -> 440 and one
with no size went 900 -> 440. Three cases now hold the scale, the mapping, and
the rule that no call site may name a size the table does not know -- an unknown
name is passed to Mantine, ignored, and silently rendered at the default.
Tested in a real browser: none of it is visible to jsdom -- the listener is on
window with capture, the stacking is z-index, and "still visible behind" is
geometry. Note that `should('be.visible')` is the wrong assertion for the
parent: it is an occlusion check, so a child drawn correctly over its parent
trips it. The test uses a wide viewport and asserts the parent is rendered and
shows to both sides of the child. Planting the pre-fix behaviour reddens three.
1124 jest / 62 suites, 8 in modal-stack.cy.js, clean npm run build. The 12
failures in Tags/Channels/Download/Inventory.cy.js are the known pre-existing
Semantic-era selector failures, unchanged.
2026-08-02 15:26:15 -06:00
|
|
|
});
|
|
|
|
|
});
|