diff --git a/css/components/search.scss b/css/components/search.scss index 6d5b2e7..2f9f007 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 9ab04e2..4ac8889 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 abfbdb8..143f8c9 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 16d8392..345d4e2 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 0000000..8cb9239 --- /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 186e2c7..9336a2a 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'),