From 82fdf9dd920fc58eb310ad0ffa60cf1f9619fb10 Mon Sep 17 00:00:00 2001 From: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:06:18 -0600 Subject: [PATCH] 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. --- app/src/components/FileBrowser.js | 37 +++++++++++++--- app/src/components/ThemeSamplePage.js | 23 ++++++++++ app/src/components/ui/Overlays.tsx | 8 +++- app/src/components/ui/modal-stack.cy.js | 58 +++++++++++++++++++++++++ app/src/components/ui/ui.css | 42 ++++++++++++++++++ 5 files changed, 160 insertions(+), 8 deletions(-) diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index 8220d253..371e4e27 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -710,11 +710,11 @@ export function MoveModal({open, paths, onClose, onSubmit}) { ); /* - * 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 @@ -925,7 +925,12 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete}) const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory; - return + /* + * 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 Upload to: {displayPath}
e.preventDefault()}> @@ -940,8 +945,26 @@ export function FileBrowserUploadModal({open, onClose, destination, onComplete})
- -
+ {/* 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. */} + +
Click here, or drop files here to upload
diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 39e38bdd..377f2925 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -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() { +

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.

+

+ fullScreen 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 fullscreen + size is a different thing: full width, height still from the content. +

+ setFullModalOpen(false)} fullScreen> + Upload to: /media/wrolpi + +

+ The body takes the leftover height, so the actions sit at the foot of + the dialog rather than wherever the content happened to end. +

+
+ + + +
setModalOpen(false)} size='small'> Restore Backup: channels.yaml diff --git a/app/src/components/ui/Overlays.tsx b/app/src/components/ui/Overlays.tsx index 2471eafe..ffc4d4dd 100644 --- a/app/src/components/ui/Overlays.tsx +++ b/app/src/components/ui/Overlays.tsx @@ -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 = { mini: 'xs', tiny: 'sm', diff --git a/app/src/components/ui/modal-stack.cy.js b/app/src/components/ui/modal-stack.cy.js index 46592ca0..6608fadb 100644 --- a/app/src/components/ui/modal-stack.cy.js +++ b/app/src/components/ui/modal-stack.cy.js @@ -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 = {}}> + Upload +

drop files here

+ +
; + + 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); + }); + }); +}); diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css index c48482e4..a1647676 100644 --- a/app/src/components/ui/ui.css +++ b/app/src/components/ui/ui.css @@ -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;