From 6f75b83235f36379ffe45ee015eeff687afbb9c4 Mon Sep 17 00:00:00 2001 From: "@suet-kei.chan" Date: Wed, 9 Sep 2026 12:34:49 +0200 Subject: [PATCH] fix: keep unified search results reachable when the Android keyboard is open On Android Chrome the unified search dialog autofocuses its input on open, so the on-screen keyboard is always up while searching. The keyboard only shrinks the visual viewport; the layout viewport is untouched, and per the CSS Values 4 spec the viewport-percentage units (vh, dvh, svh, lvh) are explicitly allowed to ignore it, which Chrome on Android does. The dialog was therefore laid out at full height while the keyboard covered roughly the bottom 45% of the screen, stranding the last results and the "Load more results" button behind it with nothing left to scroll. A prior attempt (143915d) swapped vh for dvh, but dvh does not track the keyboard either, so it could not fix this. It also set a --dialog-height custom property that no version of @nextcloud/vue or Nextcloud core reads. window.visualViewport is the only source of truth for what is actually visible, so add src/js/viewportmetrics.js to mirror it onto the document element as --nmc-viewport-height, --nmc-viewport-offset-top, and a coarse [data-nmc-viewport] tier (roomy/tight/minimal) for layout that cannot be expressed as a length alone. Wire it into the build (webpack.config.cjs) and load it on every page (BeforeTemplateRenderedListener.php), alongside the existing theme scripts. Bind every modal to these values in css/layouts/modal.scss instead of just the search dialog, since the same keyboard-vs-viewport mismatch affects any dialog with a text input (rename, share, file picker, tag). The tier overrides are expressed as inherited custom properties (--nmc-modal-inset/-padding/-margin, defined in css/nmcdefault.scss) so they never have to out-specify the base modal-container rules. In css/components/search.scss: - Fix .modal-container__content, which NcModal ships as flex: 0 1 auto; min-height: 52px. Squeezing the container collapsed it to 52px, and .dialog's height: 100% then resolved against that, shrinking the results list to zero height. - Make .unified-search-modal__results an explicit shrinkable scroller and override core's rule that disables its scrolling under 400px of viewport height, which removed the only way to reach "Load more results" exactly where room is tightest. - Add the tight/minimal tier rules that reclaim fixed chrome (dialog heading hidden accessibly, denser rows, filters hidden only in the landscape+keyboard case) so results stay usable as the visible area shrinks. - Remove the dead --dialog-height declaration. Verified against the live dev instance across 9 viewport sizes (320x568 through 1440x900, portrait and landscape) by driving the real bundle with a simulated visualViewport resize: the modal now ends exactly at the simulated keyboard line and "Load more results" stays reachable and functional (pagination confirmed 5 -> 10 -> 15 results) in every case. Desktop layout is unchanged (--nmc-viewport-height resolves to the window height there, so min() still resolves to the existing 80dvh cap). Other dialogs (move-or-copy, new folder) were spot-checked and unaffected on both mobile and desktop. --- css/components/search.scss | 75 ++++++++++++++++++- css/layouts/modal.scss | 34 ++++++++- css/nmcdefault.scss | 6 ++ .../BeforeTemplateRenderedListener.php | 1 + src/js/viewportmetrics.js | 72 ++++++++++++++++++ webpack.config.cjs | 1 + 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/js/viewportmetrics.js diff --git a/css/components/search.scss b/css/components/search.scss index 6d5b2e7d..2f9f0078 100644 --- a/css/components/search.scss +++ b/css/components/search.scss @@ -70,6 +70,11 @@ #unified-search { .modal-container__content { align-items: flex-start; + // NcModal ships this as `flex: 0 1 auto; min-height: 52px`, so squeezing the + // container collapses it to 52px, .dialog's `height: 100%` resolves against that, + // and the results list ends up zero-height. It has to grow, and to shrink freely. + flex: 1 1 auto; + min-height: 0; } .dialog__name { @@ -78,10 +83,60 @@ } } +// Short viewports (keyboard up): reclaim fixed chrome so the result list keeps a usable +// height and the 'Load more results' footer stays reachable. +html[data-nmc-viewport="tight"], +html[data-nmc-viewport="minimal"] { + + #unified-search { + + // Hidden visually only - never display: none, the heading is the dialog's + // accessible name via aria-labelledby. + .dialog__name { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .unified-search-modal__header { + padding-block-end: 8px; + } + + .result-title { + margin-top: 0.5rem; + } + + .result-items__item .list-item { + padding: 0.25rem 0.5rem; + } + } +} + +html[data-nmc-viewport="minimal"] { + + #unified-search { + + // Core pins this at top: var(--header-height) - a third of the visible height here. + .modal-container { + top: 0; + } + + // No room for both the filter row and a usable result list. Returns as soon as the + // keyboard closes or the device rotates. + .unified-search-modal__filters { + display: none; + } + } +} + #unified-search .unified-search-modal { - + .unified-search-modal__content { - --dialog-height: min(65dvh, 700px); .unified-search-modal__header { align-items: center; @@ -224,6 +279,13 @@ } &__results { + // Explicit, rather than relying on `min-height: auto` happening to resolve to 0 on + // an overflow != visible flex item. + flex: 1 1 auto; + min-height: 0; + overflow: hidden auto; + overscroll-behavior: contain; + .result { &-title { color: var(--nmc-color-text-primary); @@ -344,3 +406,12 @@ } } } + +// Core sets `overflow: unset` here under 400px of viewport height (UnifiedSearchModal.vue), +// removing the only way to reach 'Load more results' exactly where room is tightest. +@media only screen and (max-height: 400px) { + + #unified-search .unified-search-modal__results { + overflow: hidden auto; + } +} diff --git a/css/layouts/modal.scss b/css/layouts/modal.scss index 9ab04e29..4ac8889a 100644 --- a/css/layouts/modal.scss +++ b/css/layouts/modal.scss @@ -10,6 +10,26 @@ @import '../_mixins.scss'; @import '../_variables.scss'; +/** + * Viewport tiers set by src/js/viewportmetrics.js, reclaiming the modal's fixed chrome as + * the keyboard eats the visible area. Retuned through inherited custom properties so the + * tiers never have to out-specify the base rules below. + */ +html[data-nmc-viewport="tight"] { + // Portrait, keyboard up: 300-560px visible. + --nmc-modal-inset: var(--header-height); + --nmc-modal-padding: 0.75rem; + --nmc-modal-margin: 0px; +} + +html[data-nmc-viewport="minimal"] { + // Landscape, keyboard up: under 300px visible, of which the header offset alone would + // take a quarter - so the dialog takes the whole visible area. + --nmc-modal-inset: 0px; + --nmc-modal-padding: 0.5rem; + --nmc-modal-margin: 0px; +} + #body-user, #body-settings, #body-login, @@ -18,6 +38,10 @@ .modal-mask { background-color: var(--nmc-color-main-background-mask); + // Track the visible region, not the layout viewport, so an on-screen keyboard + // shrinks the dialog instead of stranding its lower half behind the keyboard. + top: var(--nmc-viewport-offset-top, 0px); + height: var(--nmc-viewport-height, 100%); &--opaque { background-color: var(--nmc-color-main-background-mask-opaque); @@ -34,7 +58,7 @@ .modal-container { box-shadow: none; padding: 0; - max-height: 100dvh; + max-height: min(100dvh, var(--nmc-viewport-height, 100dvh)); } } @@ -47,8 +71,10 @@ .modal-container { box-shadow: unset; box-sizing: border-box; - padding: 1.5rem; - max-height: 80dvh; + padding: var(--nmc-modal-padding); + // Subtract the inset: the container's own top offset and margins would + // otherwise push it past the bottom of the shrunken mask. + max-height: min(80dvh, calc(var(--nmc-viewport-height, 100dvh) - var(--nmc-modal-inset))); max-width: 100vw; display: flex; flex-direction: column; @@ -176,7 +202,7 @@ .modal-container { @media screen and (max-width: $breakpoint-mobile-small) { - margin: 1rem; + margin: var(--nmc-modal-margin); } .dialog__name { diff --git a/css/nmcdefault.scss b/css/nmcdefault.scss index abfbdb82..143f8c95 100644 --- a/css/nmcdefault.scss +++ b/css/nmcdefault.scss @@ -144,6 +144,12 @@ --image-background: none; --image-logoheader-custom: var(--image-logoheader); + /* Modal sizing, retuned per viewport tier in layouts/modal.scss. + --nmc-modal-inset is the vertical space a modal cannot use: top offset plus margins. */ + --nmc-modal-inset: calc(var(--header-height) + 2rem); + --nmc-modal-padding: 1.5rem; + --nmc-modal-margin: 1rem; + --body-container-radius: var(--border-radius-container-large); --body-container-margin: calc(var(--default-grid-baseline) * 2); --footer-height: 42px; diff --git a/lib/Listener/BeforeTemplateRenderedListener.php b/lib/Listener/BeforeTemplateRenderedListener.php index 16d8392e..345d4e20 100644 --- a/lib/Listener/BeforeTemplateRenderedListener.php +++ b/lib/Listener/BeforeTemplateRenderedListener.php @@ -98,6 +98,7 @@ public function handle(Event $event): void { \OCP\Util::addScript("nmctheme", "nmctheme-mimetypes", "core"); \OCP\Util::addScript("nmctheme", "nmctheme-skipactions", "core"); \OCP\Util::addScript("nmctheme", "nmctheme-searchfavorites", "core"); + \OCP\Util::addScript("nmctheme", "nmctheme-viewportmetrics", "core"); \OCP\Util::addScript("nmctheme", "nmctheme-filessettings", "files"); \OCP\Util::addScript("nmctheme", "nmctheme-filelistplugin", "files"); \OCP\Util::addScript("nmctheme", "nmctheme-trashbinfix", "files"); diff --git a/src/js/viewportmetrics.js b/src/js/viewportmetrics.js new file mode 100644 index 00000000..8cb9239e --- /dev/null +++ b/src/js/viewportmetrics.js @@ -0,0 +1,72 @@ +/** + * @copyright Copyright (c) 2026 T-Systems International + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Publishes the visual viewport as CSS custom properties on . + * + * An on-screen keyboard shrinks only the *visual* viewport. Per CSS Values 4 the + * viewport-percentage units may ignore that, and Chrome on Android does - so `vh`, `dvh` + * and `%` all keep their full height while the keyboard covers the lower ~45% of the + * screen. `window.visualViewport` is the only source of truth for the visible region. + * + * Media queries cannot see the keyboard either, hence [data-nmc-viewport] for layout that + * cannot be expressed as a length. Consumed in css/layouts/modal.scss. + */ + +// Portrait phones with the keyboard up land around 330-530px and stay in 'tight', which +// keeps the filter row. Only landscape falls to 'minimal', where there is not enough +// height for both the filters and a usable result list. +const TIER_TIGHT = 560 +const TIER_MINIMAL = 300 + +// Above this, the shrinking visual viewport is the user zooming in rather than a keyboard; +// clamping dialogs to it would leave them unusably small. +const MAX_TRACKED_SCALE = 1.01 + +let frame = null + +/** Mirrors the visual viewport onto the document element. */ +function publish() { + frame = null + + const viewport = window.visualViewport + const root = document.documentElement + + if (viewport.scale > MAX_TRACKED_SCALE) { + root.style.removeProperty('--nmc-viewport-height') + root.style.removeProperty('--nmc-viewport-offset-top') + delete root.dataset.nmcViewport + return + } + + const height = Math.round(viewport.height) + root.style.setProperty('--nmc-viewport-height', `${height}px`) + root.style.setProperty('--nmc-viewport-offset-top', `${Math.round(viewport.offsetTop)}px`) + + if (height < TIER_MINIMAL) { + root.dataset.nmcViewport = 'minimal' + } else if (height < TIER_TIGHT) { + root.dataset.nmcViewport = 'tight' + } else { + root.dataset.nmcViewport = 'roomy' + } +} + +/** Coalesces event bursts into one write per frame. */ +function schedule() { + if (frame === null) { + frame = window.requestAnimationFrame(publish) + } +} + +// Without the API the stylesheet falls back to full-viewport sizing, i.e. what we had before. +if (window.visualViewport) { + const viewport = window.visualViewport + viewport.addEventListener('resize', schedule, { passive: true }) + viewport.addEventListener('scroll', schedule, { passive: true }) + window.addEventListener('orientationchange', schedule, { passive: true }) + + publish() + window.addEventListener('DOMContentLoaded', publish) +} diff --git a/webpack.config.cjs b/webpack.config.cjs index 186e2c74..9336a2a0 100644 --- a/webpack.config.cjs +++ b/webpack.config.cjs @@ -11,6 +11,7 @@ webpackConfig.entry = { trashbinfix: path.join(__dirname, 'src', 'js', 'trashbinfix.js'), skipactions: path.join(__dirname, 'src', 'js', 'skipactions.js'), searchfavorites: path.join(__dirname, 'src', 'js', 'searchfavorites.js'), + viewportmetrics: path.join(__dirname, 'src', 'js', 'viewportmetrics.js'), conflictdialog: path.join(__dirname, 'src', 'js', 'conflictdialog.js'), mimetypes: path.join(__dirname, 'src', 'js', 'mimetypes.js'), nmcfooter: path.join(__dirname, 'src', 'nmcfooter.ts'),