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.
This commit is contained in:
Roland 2026-08-02 15:26:15 -06:00
parent 2aa5360932
commit 00978bc647
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
20 changed files with 382 additions and 27 deletions

View file

@ -251,7 +251,7 @@ export function Getters() {
let getterModal;
if (selectedGetter === 'downloads') {
getterModal = <Modal closeIcon
getterModal = <Modal size='large' closeIcon
open={true}
centered={false}
onClose={() => handleSetGetter(null, null)}
@ -266,7 +266,7 @@ export function Getters() {
</Modal.Content>
</Modal>;
} else if (selectedGetter === 'upload') {
getterModal = <Modal closeIcon
getterModal = <Modal size='large' closeIcon
open={true}
centered={false}
onClose={() => handleSetGetter(null, null)}

View file

@ -332,7 +332,7 @@ function EditTagsModal() {
];
return <>
<Modal open={open} onClose={localOnClose}>
<Modal size='large' open={open} onClose={localOnClose}>
<Modal.Header>Edit Tags</Modal.Header>
<div id='editModalContent'>
<Group gap={6}>
@ -567,7 +567,7 @@ export function AddTagsButton({
type='button'
disabled={disabled}
/>
<Modal open={open} onClose={() => setOpen(false)}>
<Modal size='small' open={open} onClose={() => setOpen(false)}>
<Modal.Content>
{loading && <Loading size='xs'>Updating tags…</Loading>}
<Header as='h4'>Applied Tags</Header>

View file

@ -125,7 +125,7 @@ export function BulkTagModal({open, onClose, paths, onComplete}) {
: 0;
return (
<Modal closeIcon open={open} onClose={handleClose} size="small">
<Modal closeIcon open={open} onClose={handleClose} size='large'>
<Modal.Header>Bulk Tag Files</Modal.Header>
<Modal.Content>
{state === 'loading' && (

View file

@ -309,7 +309,7 @@ export function ChannelEditPage() {
New Download
</Button>
</Group>
<Modal open={downloadModalOpen} onClose={() => setDownloadModalOpen(false)}>
<Modal size='small' open={downloadModalOpen} onClose={() => setDownloadModalOpen(false)}>
<Modal.Content>
<Header as='h2'>New Channel Download</Header>
<ChannelDownloadForm

View file

@ -1674,7 +1674,7 @@ export function SortButton({sorts = []}) {
}
return <>
<Modal closeIcon
<Modal size='small' closeIcon
open={open}
onClose={() => setOpen(false)}
>

View file

@ -18,7 +18,7 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) {
return <>
<IconButton icon='qrcode' label={`Show ${header} QR code`} color={buttonColor}
onClick={() => setOpen(true)}/>
<Modal closeIcon
<Modal size='small' closeIcon
open={open}
onClose={() => setOpen(false)}
onOpen={() => setOpen(true)}

View file

@ -420,7 +420,7 @@ export function FilePreviewProvider({children}) {
setErrorModalOpen(false);
}
const errorModal = <Modal open={errorModalOpen}
const errorModal = <Modal size='large' open={errorModalOpen}
onClose={handleErrorModalClose}
>
<Modal.Header>Unknown File</Modal.Header>

View file

@ -644,7 +644,7 @@ export function SearchFilterModal(
>{i}</Button>
);
return <Modal open={open} onClose={handleClose} closeIcon size='small'>
return <Modal open={open} onClose={handleClose} closeIcon size='large'>
<Modal.Header>Search Filters</Modal.Header>
<Modal.Content>
<Stack gap='lg'>

View file

@ -943,7 +943,7 @@ export function FlasherPage() {
</>}
</Panel>
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='sm'>
<Modal open={saveModalOpen} onClose={() => setSaveModalOpen(false)} size='tiny'>
<Modal.Header>Save firmware configuration</Modal.Header>
<Modal.Content>
<TextInput

View file

@ -81,7 +81,7 @@ function SearchModal({open, onClose}) {
if (!open) return null;
return (
<Modal open={open} onClose={onClose} centered={false} title='Search'>
<Modal size='small' open={open} onClose={onClose} centered={false} title='Search'>
<Modal.Content>
{/*
* `wrolpi-search-modal` makes the suggestion list part of the panel rather

View file

@ -115,7 +115,7 @@ function RegionPreviewModal({bbox, name, open, onClose}) {
return () => { if (map) map.remove(); };
}, [open, bbox]);
return <Modal open={open} onClose={onClose} size='large' closeIcon title={name}>
return <Modal open={open} onClose={onClose} size='fullscreen' closeIcon title={name}>
{/* No `.media` here: the bbox highlight is drawn as a map layer on the canvas itself
(see the `bbox` source/layers above), so the automatic `canvas` element-type filter
already covers it. There is no separate DOM overlay to wrap. */}

View file

@ -38,7 +38,7 @@ export const ShareButton = () => {
}
return <>
<Modal closeIcon
<Modal size='small' closeIcon
open={open}
onClose={handleClose}
>

View file

@ -110,7 +110,7 @@ export const OutdatedZimsMessage = ({onClick}) => {
<p></p>
<Button role='danger' onClick={() => setOpen(true)}>Delete</Button>
<Modal open={open}
<Modal size='small' open={open}
onClose={onClose}
title='Delete'
>
@ -165,7 +165,7 @@ const ZimSearchEntry = ({zimId, onTag, onUntag, entry}) => {
{tagIcon}
</Header>
<HeadlineText headline={headline}/>
<Modal open={open}
<Modal size='large' open={open}
onClose={() => setOpen(false)}>
<div className='preview-fit'>
<ZimViewer src={url} style={{

View file

@ -1383,7 +1383,7 @@ function SambaSection() {
<Modal open={addOpen} onClose={() => {
setAddOpen(false);
setShareAll(true);
}} size='small'>
}} size='large'>
<Modal.Header>Add Samba Share</Modal.Header>
<Modal.Content>
<TextInput
@ -1589,7 +1589,7 @@ function HotspotSettingsForm() {
style={{marginLeft: '0.5em'}}
onClick={() => setQrOpen(true)}
/>
<Modal open={qrOpen} onClose={() => setQrOpen(false)} closeIcon>
<Modal size='small' open={qrOpen} onClose={() => setQrOpen(false)} closeIcon>
<Modal.Header>Scan this code to join the hotspot</Modal.Header>
<Modal.Content>
{/* `media`: filtered as a unit by night mode; a QR code needs real black-on-white

View file

@ -64,7 +64,7 @@ function DownloadProgressModal({progress, url}) {
<Button size='small' onClick={() => setOpen(true)} style={{marginLeft: '0.5em'}}>
{buttonLabel}
</Button>
<Modal closeIcon open={open} onClose={() => setOpen(false)} size='small'>
<Modal closeIcon open={open} onClose={() => setOpen(false)} size='fullscreen'>
<Modal.Header>Download Progress</Modal.Header>
<Modal.Content>
<Progress
@ -224,7 +224,7 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) {
(text) => <Link to={location}>{text}</Link> :
(text) => <a href={url} target='_blank' rel='noopener noreferrer'>{text}</a>;
const errorModal = <Modal
const errorModal = <Modal size='fullscreen'
closeIcon
onClose={handleErrorClose}
open={errorModalOpen}
@ -308,7 +308,7 @@ function RecurringDownloadRow({download, fetchDownloads, onDelete}) {
/>;
}
const editModal = <Modal closeIcon
const editModal = <Modal size='large' closeIcon
open={editModalOpen}
onClose={handleEditClose}
>
@ -456,7 +456,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
<IconButton icon='exclamation triangle' label='Download error' color='danger'
onClick={() => setErrorModalOpen(true)}/>
)
errorModal = <Modal
errorModal = <Modal size='fullscreen'
closeIcon
open={errorModalOpen}
onClose={() => setErrorModalOpen(false)}
@ -470,7 +470,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
if (downloader === Downloaders.Video) {
editModal = (
<Modal
<Modal size='large'
closeIcon
open={editModalOpen}
onClose={handleEditClose}
@ -500,7 +500,7 @@ function OnceDownloadRow({download, fetchDownloads, isSelected, onSelect}) {
);
} else if (downloader === Downloaders.Archive) {
editModal = (
<Modal
<Modal size='large'
closeIcon
open={editModalOpen}
onClose={handleEditClose}

View file

@ -98,7 +98,7 @@ export function CollectionTagModal({
const saveButtonText = hasDirectory && moveToTagDirectory ? 'Move' : 'Save';
return (
<Modal
<Modal size='large'
open={open}
onClose={handleClose}
closeIcon

View file

@ -36,7 +36,7 @@ function NewInventoryModal({open, onClose, onCreate}) {
}
};
return <Modal open={open} onClose={onClose} closeIcon>
return <Modal size='small' open={open} onClose={onClose} closeIcon>
<Modal.Header>New Inventory</Modal.Header>
<Modal.Content>
<TextInput autoFocus label='Name' value={name} onChange={e => setName(e.currentTarget.value)}

View file

@ -45,6 +45,78 @@ export interface ModalProps extends Omit<React.ComponentProps<typeof MModal>, 'o
closeIcon?: boolean;
}
/*
* Nested modals: which one is on top.
*
* Mantine binds a window-level `keydown` listener per open modal, gated only on whether that
* modal is open -- there is no check for whether it is the one the user is looking at. Two
* modals open meant two listeners, so a single Escape closed BOTH: opening the search modal
* over the dashboard's download modal and pressing Escape once left you on the dashboard.
* Semantic handled this; the migration lost it.
*
* Mantine's own answer is `Modal.Stack` with a `stackId` per modal, which is the only thing
* that gates `closeOnEscape`. It is not used here for two reasons: it requires every call
* site 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. We want the parent dimmed
* and still visible behind the child, which is what it did before.
*
* So the wrapper keeps the register itself. A module-level list 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 while the download modal is
* rendered by the dashboard. They are siblings that happen to be open at once, and no
* context relates them. Most recently opened is on top.
*/
let openModalIds: string[] = [];
const modalStackListeners = new Set<() => void>();
const notifyModalStack = () => modalStackListeners.forEach(listener => listener());
/** Mantine's default modal z-index; popovers and tooltips sit at 300. */
const MODAL_Z_INDEX = 200;
/**
* Where this modal sits in the stack of open ones.
*
* Exported for the tests, which need to assert the register empties -- a modal that failed to
* deregister would leave every later modal believing it was not on top, and nothing would
* close on Escape at all.
*/
export const openModalCount = () => openModalIds.length;
function useModalStackPosition(opened: boolean): {isTop: boolean; zIndex: number} {
const id = React.useId();
const [, rerender] = React.useReducer((count: number) => count + 1, 0);
React.useEffect(() => {
modalStackListeners.add(rerender);
return () => {
modalStackListeners.delete(rerender);
};
}, []);
React.useEffect(() => {
if (!opened) return;
// Re-opening an already-registered modal moves it to the top, which is what a user
// who clicked its trigger again means.
openModalIds = [...openModalIds.filter(other => other !== id), id];
notifyModalStack();
return () => {
openModalIds = openModalIds.filter(other => other !== id);
notifyModalStack();
};
}, [opened, id]);
const depth = openModalIds.indexOf(id);
return {
isTop: depth === -1 || depth === openModalIds.length - 1,
/*
* Two per level, so a child's overlay clears its parent's content. Kept small on
* purpose: Mantine puts popovers and select dropdowns at 300, and a modal stack that
* climbed past that would paint over the dropdowns inside itself.
*/
zIndex: MODAL_Z_INDEX + Math.max(depth, 0) * 2,
};
}
function ModalBase({open, opened, size, closeIcon, title, children, ...props}: ModalProps) {
const slots: Record<string, React.ReactNode[]> = {header: [], actions: [], body: []};
React.Children.forEach(children, child => {
@ -54,13 +126,25 @@ function ModalBase({open, opened, size, closeIcon, title, children, ...props}: M
else slots.body.push(child);
});
const isOpen = opened ?? open ?? false;
const {isTop, zIndex} = useModalStackPosition(isOpen);
return <MModal
opened={opened ?? open ?? false}
opened={isOpen}
centered
size={typeof size === 'string' ? (modalSizes[size] ?? size) : size}
title={slots.header.length ? slots.header : title}
// Flat surfaces: the overlay separates the modal from the page, not a shadow.
overlayProps={{backgroundOpacity: 0.55, blur: 0}}
/*
* Only the modal on top answers the keyboard. Without this every open modal has its
* own window-level Escape listener and one press closes the lot. Focus goes with it:
* two traps fighting over the same Tab is its own bug, and the user is working in the
* top one.
*/
closeOnEscape={isTop}
trapFocus={isTop}
zIndex={zIndex}
{...props}
>
{slots.body}

View file

@ -0,0 +1,199 @@
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 titles = () => [...Cypress.$('.mantine-Modal-title')].map(el => el.textContent);
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');
expect(titles()).to.not.include('Parent');
});
});

View file

@ -1487,3 +1487,75 @@ describe('Confirm', () => {
expect(screen.getByRole('button', {name: 'Wipe'})).toHaveClass('wrolpi-button-danger');
});
});
describe('modal sizes', () => {
/*
* The size table is the single thing that halved every modal in the migration.
*
* Semantic's names were measured on the pre-migration build: mini 360, tiny 540, small
* 720, large 1080, fullscreen 95%. They are mapped onto Mantine's much smaller scale, so
* a modal that said `small` went from 720px to 440px and one that said nothing at all
* went from 900px to 440px. That is deliberate now -- the sizes were audited call site
* by call site against this scale -- but it means the table IS the design, and a quiet
* edit to it moves seventy modals at once.
*/
const MANTINE_PX = {xs: 320, sm: 380, md: 440, lg: 620, xl: 780};
it('resolves each name to the width the audit was done against', () => {
// Read out of Mantine's own stylesheet rather than restated, so a version bump that
// changes the scale fails here instead of silently resizing the app.
const css = fs.readFileSync(
path.join(__dirname, '../../../node_modules/@mantine/core/styles.css'), 'utf8');
Object.entries(MANTINE_PX).forEach(([name, px]) => {
const declared = css.match(
new RegExp(`--modal-size-${name}:\\s*calc\\(([\\d.]+)rem`));
expect({name, px: declared && Math.round(parseFloat(declared[1]) * 16)})
.toEqual({name, px});
});
});
it('maps every Semantic name onto that scale', () => {
// The mapping the call sites are written against. `fullscreen` is the one that did
// not shrink, and the one several audited modals were moved TO.
const source = fs.readFileSync(path.join(__dirname, 'Overlays.tsx'), 'utf8');
const table = source.match(/const modalSizes[^=]*=\s*{([^}]*)}/)[1];
const pairs = Object.fromEntries([...table.matchAll(/(\w+):\s*'([^']+)'/g)]
.map(m => [m[1], m[2]]));
expect(pairs).toEqual({
mini: 'xs', tiny: 'sm', small: 'md', large: 'lg', fullscreen: '100%',
});
});
it('never lets a call site name a size the table does not know', () => {
/*
* A name the table has never heard of is passed straight to Mantine, which ignores
* anything it does not recognise and renders the default -- so a typo is a modal that
* silently reverts to 440px with no warning anywhere. Flasher was passing Mantine's
* own `sm` directly, which worked but bypassed the naming the audit used.
*/
const known = new Set(['mini', 'tiny', 'small', 'large', 'fullscreen']);
const offenders = [];
const walk = (directory) => fs.readdirSync(directory, {withFileTypes: true})
.flatMap(entry => {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : walk(full);
if (!/\.(js|jsx|tsx)$/.test(entry.name)) return [];
if (/\.(test|cy)\./.test(entry.name)) return [];
return [[full, fs.readFileSync(full, 'utf8')]];
});
for (const [file, source] of walk(path.join(__dirname, '..', '..'))) {
// Only `<Modal ... size='x'>`; a computed `size={...}` is the caller's business.
for (const match of source.matchAll(/<Modal\b[^>]*?\bsize='([^']+)'/g)) {
if (!known.has(match[1])) {
offenders.push(`${path.basename(file)}: size='${match[1]}'`);
}
}
}
expect(offenders).toEqual([]);
});
});