ui: an upload target smaller than the dialog around it (#663)

The upload dialog was 682x367 on a 1440x900 desktop -- 47% of the width, 41% of the
height -- and the drop target inside it was a strip about 90px tall.  A small thing to
drag a file onto, with the file browser it came from covering most of the screen behind.

Upload and Move ask for `fullScreen`, which is edge to edge on a phone and 90% above the
tablet breakpoint: filling a desktop reads as the app having been replaced rather than as
something opened over it.  Move holds a DirectorySearch above a row per path being moved,
which grows with the selection; 620px showed a handful and scrolled the rest.

Two things had to follow.  Mantine sizes a fullscreen dialog's content to the viewport but
leaves the body at its content height, so the footer landed two thirds up the screen with
a quarter of the viewport empty beneath it -- the body takes the leftover now and the
actions ride to the bottom of it.  And the drop target itself grows into the room, with a
floor so it stays large once progress bars claim their own space.

Rename and the confirmations keep their sizes: a fullscreen dialog for one text field or
one sentence reads as broken.  New Folder keeps `large` too -- it is a text field and a
path preview, the same shape as rename, not the directory picker its name suggests.

`size='fullscreen'` is a DIFFERENT thing -- full width, height from the content, which is
what a log or a preview wants.  Both are documented where the sizes are declared, and the
gallery grows a fullscreen example so the difference is visible in all four themes.

339 component tests pass, 69 suites / 1214 jest tests pass, npm run build is clean.
This commit is contained in:
lrnselfreliance 2026-08-13 13:06:18 -06:00 • committed by GitHub
parent 67c3afbec9
commit 82fdf9dd92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 160 additions and 8 deletions

View file

@ -710,11 +710,11 @@ export function MoveModal({open, paths, onClose, onSubmit}) {
</Table.Row>);
/*
* Audited: `large` (620px). This dialog holds a DirectorySearch and a two-column
* Source/Destination table of every path being moved, which is the densest content
* of the five FileBrowser modals.
* Fullscreen: a DirectorySearch above a two-column Source/Destination table of every
* path being moved, which grows with the selection. 620px showed a handful of rows and
* scrolled the rest.
*/
return <Modal size='large'
return <Modal fullScreen
onClose={onClose}
open={open}
>
@ -925,7 +925,12 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete})
const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory;
return <Modal open={open} onClose={handleClose} size='large'>
/*
* Fullscreen, for the drop target. At 620px the target was a strip about 90px tall in a
* dialog covering a fifth of the screen -- a small thing to drag a file onto, and the
* page behind it was the file browser it came from. Given the room, the target takes it.
*/
return <Modal open={open} onClose={handleClose} fullScreen>
<Modal.Header>Upload to: {displayPath}</Modal.Header>
<Modal.Content>
<form onSubmit={e => e.preventDefault()}>
@ -940,8 +945,26 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete})
</div>
</form>
<Panel style={{marginTop: '1em', padding: '1em'}}>
<div {...getRootProps()} style={{cursor: 'pointer', textAlign: 'center'}}>
{/* The target takes the room the dialog gives it rather than sitting as a strip
at the top of it. `min-height` as well as `flex`, so it stays a large target
once the progress bars below it start claiming their own space. */}
<Panel style={{
marginTop: '1em',
padding: '1em',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
}}>
<div {...getRootProps()}
style={{
cursor: 'pointer',
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: '1 1 auto',
minHeight: '30vh',
}}>
<input {...getInputProps()}/>
<Header as='h4' icon='file text'>Click here, or drop files here to upload</Header>
</div>

View file

@ -166,6 +166,7 @@ export function ThemeSamplePage() {
const [logLevel, setLogLevel] = useState(3);
const [dismissed, setDismissed] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [fullModalOpen, setFullModalOpen] = useState(false);
const [realColors, setRealColors] = useState(false);
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState('Videos');
@ -1003,11 +1004,33 @@ export function ThemeSamplePage() {
<Panel>
<Row>
<Button role='cancel' icon='eye' onClick={() => setModalOpen(true)}>Open a modal</Button>
<Button role='cancel' icon='expand arrows alternate' onClick={() => setFullModalOpen(true)}>
Open a fullscreen modal
</Button>
</Row>
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginBottom: 0, marginTop: 10}}>
A modal is compound — Modal.Header, Modal.Content, Modal.Actions — so the
markup at a call site reads the way the dialog is laid out.
</p>
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginBottom: 0, marginTop: 10}}>
<code>fullScreen</code> is for a dialog whose content wants the room — a drop
target, a directory browser, a log. It is edge to edge on a phone and 90% on a
desktop, where filling the screen would read as the app having been replaced
rather than as something opened over it. Note that the <code>fullscreen</code>
<em>size</em> is a different thing: full width, height still from the content.
</p>
<Modal open={fullModalOpen} onClose={() => setFullModalOpen(false)} fullScreen>
<Modal.Header>Upload to: /media/wrolpi</Modal.Header>
<Modal.Content>
<p style={{marginTop: 0}}>
The body takes the leftover height, so the actions sit at the foot of
the dialog rather than wherever the content happened to end.
</p>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={() => setFullModalOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
<Modal open={modalOpen} onClose={() => setModalOpen(false)} size='small'>
<Modal.Header>Restore Backup: channels.yaml</Modal.Header>
<Modal.Content>

View file

@ -23,7 +23,13 @@ ModalContent.displayName = 'Modal.Content';
const ModalActions = ({children}: {children?: React.ReactNode}) => <>{children}</>;
ModalActions.displayName = 'Modal.Actions';
// WROLPi's size names, mapped onto Mantine's scale. See `sizeAliases` in Button.tsx.
/*
* WROLPi's size names, mapped onto Mantine's scale. See `sizeAliases` in Button.tsx.
*
* `fullscreen` is the full WIDTH; the height still follows the content, which is what a log
* or a preview wants. For a dialog that should fill the viewport in both directions, pass
* Mantine's `fullScreen` prop instead -- it forwards through, and the two are not the same.
*/
const modalSizes: Record<string, string> = {
mini: 'xs',
tiny: 'sm',

View file

@ -253,3 +253,61 @@ describe('a parent that disables Escape while it is busy', () => {
.should('exist');
});
});
describe('a dialog that fills the screen', () => {
/*
* The upload dialog held a drop target about 90px tall inside a dialog covering a fifth
* of a desktop. It asks for `fullScreen` now, which is edge to edge on a phone and 90%
* on a desktop -- big enough to aim a file at, with the page still framing it.
*
* Worth a browser test on two counts: the wrapper has to FORWARD `fullScreen` (it
* destructures several props and could swallow this one), and the 90% is a media query,
* so neither half is visible to jsdom.
*/
const dialog = <Modal open fullScreen onClose={() => {}}>
<Modal.Header>Upload</Modal.Header>
<Modal.Content><p>drop files here</p></Modal.Content>
<Modal.Actions><Button role='cancel'>Close</Button></Modal.Actions>
</Modal>;
const box = ($el) => $el[0].getBoundingClientRect();
it('fills a phone edge to edge', () => {
cy.viewport(390, 844);
cy.mountUI(dialog);
cy.get('.mantine-Modal-content').should(($content) => {
const r = box($content);
expect(r.width, 'full width').to.be.closeTo(390, 1);
expect(r.height, 'full height').to.be.closeTo(844, 1);
});
});
it('stops at 90% on a desktop, so the page still frames it', () => {
cy.viewport(1440, 900);
cy.mountUI(dialog);
cy.get('.mantine-Modal-content').should(($content) => {
const r = box($content);
expect(r.width, '90% of the width').to.be.closeTo(1440 * 0.9, 2);
expect(r.height, '90% of the height').to.be.closeTo(900 * 0.9, 2);
// Framed rather than merely smaller: page shows on both sides.
expect(r.left, 'centred').to.be.greaterThan(0);
expect(r.right, 'centred').to.be.lessThan(1440);
});
});
it('puts the actions at the bottom of the dialog, not under the content', () => {
// Mantine sizes the content to the viewport but leaves the body at its content
// height, which left the footer two thirds up the screen with the rest empty.
cy.viewport(1440, 900);
cy.mountUI(dialog);
cy.get('.mantine-Modal-content').should(($content) => {
const actions = $content[0].querySelector('.wrolpi-modal-actions');
expect(actions, 'the dialog has an actions row').to.not.equal(null);
expect(box($content).bottom - actions.getBoundingClientRect().bottom,
'the actions sit at the foot of the dialog').to.be.at.most(2);
});
});
});

View file

@ -494,6 +494,48 @@ html .wrolpi-searchbox-clear {
border-top: 1px solid var(--border);
}
/*
* A fullscreen dialog fills its height as well as its width.
*
* Mantine gives the content 100vh but leaves the body at its content height, so the footer
* landed wherever the content ended -- on the upload dialog, two thirds up the screen with a
* quarter of the viewport empty beneath it. The body takes the leftover instead, and the
* actions ride to the bottom of it.
*
* `data-full-screen` is Mantine's own marker, so this follows the prop rather than a class a
* call site has to remember.
*/
.mantine-Modal-content[data-full-screen] {
display: flex;
flex-direction: column;
}
/*
* Edge to edge is for a phone, where the alternative is a dialog with a few pixels of page
* showing around it. On a desktop it reads as the app having been replaced rather than as
* something opened on top of it, so the dialog stops at 90% and leaves the page framing it.
*
* 700px is the app's `tablet` breakpoint (`AppMedia` in contexts.js).
*/
@media (min-width: 700px) {
.mantine-Modal-content[data-full-screen] {
width: 90vw;
max-width: 90vw;
height: 90vh;
max-height: 90vh;
}
}
.mantine-Modal-content[data-full-screen] .mantine-Modal-body {
flex: 1;
display: flex;
flex-direction: column;
}
.mantine-Modal-content[data-full-screen] .wrolpi-modal-actions {
margin-top: auto;
}
/* A confirmation's buttons: under the question, no separating rule. See Confirm. */
.wrolpi-confirm-actions {
display: flex;