Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 73 additions & 2 deletions css/components/search.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
34 changes: 30 additions & 4 deletions css/layouts/modal.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -34,7 +58,7 @@
.modal-container {
box-shadow: none;
padding: 0;
max-height: 100dvh;
max-height: min(100dvh, var(--nmc-viewport-height, 100dvh));
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -176,7 +202,7 @@

.modal-container {
@media screen and (max-width: $breakpoint-mobile-small) {
margin: 1rem;
margin: var(--nmc-modal-margin);
}

.dialog__name {
Expand Down
6 changes: 6 additions & 0 deletions css/nmcdefault.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions lib/Listener/BeforeTemplateRenderedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
72 changes: 72 additions & 0 deletions src/js/viewportmetrics.js
Original file line number Diff line number Diff line change
@@ -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 <html>.
*
* 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)
}
1 change: 1 addition & 0 deletions webpack.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading