Hide App navigation on a narrow screen until the user opens it.

A width at or below Nextcloud's 1024px breakpoint keeps the tree off the page so the content stays usable, and the navigation button brings it back.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-29 18:56:44 +02:00
co-authored by Cursor
parent 4e05fb1c6a
commit fad9806585
5 changed files with 162 additions and 5 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ The Library check that enforces Required Groups for the Requesting user on ExApp
_Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script _Avoid_: Nextcloud middleware, HaRP ACL, admin bypass, gating the top-menu bootstrap script
**App navigation**: **App navigation**:
The tree of named items along the left of an ExApp page. The ExApp supplies the tree. The selected item is the page on display. It plays the same role as the navigation in Nextcloud Files. An ExApp that does not use App navigation shows its own page instead. The tree of named items along the left of an ExApp page. The ExApp supplies the tree. The selected item is the page on display. It plays the same role as the navigation in Nextcloud Files. On a screen at most 1024px wide the tree is hidden until the user opens it; choosing an item, pressing Escape, or clicking outside closes it again. An ExApp that does not use App navigation shows its own page instead.
_Avoid_: Top Menu, sidebar, NcAppNavigation _Avoid_: Top Menu, sidebar, NcAppNavigation
**Dialog**: **Dialog**:
+1 -1
View File
@@ -18,7 +18,7 @@ Import: `gitea.neitzel.de/konrad/go-nc-exapp` (package `gonexapp`).
- **Groups** — Users and Groups reads: `UserGroups`, `GroupMembers`, `ListGroups` (no search/paging). Directory calls (`GroupMembers` / `ListGroups`) run as the Credentials user and need an admin or subadmin - **Groups** — Users and Groups reads: `UserGroups`, `GroupMembers`, `ListGroups` (no search/paging). Directory calls (`GroupMembers` / `ListGroups`) run as the Credentials user and need an admin or subadmin
- **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS` - **Access Gate** — optional Required Groups enforcement (`Wrap` + `Check`), English denied HTML for browsers (200 + `frame-ancestors 'self'`), positive membership cache; default skip for lifecycle paths and **`/js/`** top-menu scripts; env helpers for `REQUIRED_GROUPS` / `REQUIRED_GROUPS_CACHE_SECONDS`
- **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS) - **Top Menu visibility** — `TopMenuAdminRequired` helper for deploy env `TOP_MENU_ADMIN_REQUIRED` (`0` / `1` for AppAPI top-menu OCS)
- **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. By default the shell loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page’s `data-theme-*` markers; set `DisableTheme` to skip the stylesheets. An item may set `Icon` to a same-origin image URL. `NextcloudIcons` names the core SVGs Nextcloud already serves (`NextcloudIcons.Folder`, `NextcloudIcons.Password`, and the rest). The shell includes the Dialog. Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp. - **App navigation** — optional Files-style shell (`AppNavigation.Handler`). The ExApp supplies the tree, the page for each item, and an optional header. The selected item is the `item` query parameter (override with `SelectKey`). Other query parameters, including a Visit folder, are left in place. By default the shell loads the Nextcloud theme stylesheets, paints `--image-background`, and copies the surrounding page’s `data-theme-*` markers; set `DisableTheme` to skip the stylesheets. An item may set `Icon` to a same-origin image URL. `NextcloudIcons` names the core SVGs Nextcloud already serves (`NextcloudIcons.Folder`, `NextcloudIcons.Password`, and the rest). At 1024px and below, the tree is hidden until the user opens it with the navigation button; choosing an item, pressing Escape, or clicking outside closes it. The shell includes the Dialog. Not mounting the handler keeps the ExApp’s own page. Lifecycle routes, HaRP startup, and Top Menu registration stay in the ExApp.
- **Dialog** — `DialogHTML()` is the modal for a Message, a Confirm, or a Prompt. Insert it on a page the ExApp renders itself; App navigation already includes it. The page calls `exappDialog.message`, `exappDialog.confirm`, or `exappDialog.prompt` and waits for the choice. Fixed buttons are the English words OK and Cancel. The agreeing button’s word may be replaced. The choice stays in the page. - **Dialog** — `DialogHTML()` is the modal for a Message, a Confirm, or a Prompt. Insert it on a page the ExApp renders itself; App navigation already includes it. The page calls `exappDialog.message`, `exappDialog.confirm`, or `exappDialog.prompt` and waits for the choice. Fixed buttons are the English words OK and Cancel. The agreeing button’s word may be replaced. The choice stays in the page.
**Excluded** **Excluded**
+1
View File
@@ -59,6 +59,7 @@ func dialogFixture() http.Handler {
}, },
} }
mux.Handle("GET /shell", nav.Handler()) mux.Handle("GET /shell", nav.Handler())
mux.Handle("GET /nav", sampleNavigation().Handler())
return mux return mux
} }
+92 -3
View File
@@ -170,6 +170,7 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title> <title>{{.Title}}</title>
{{if .Theme}} {{if .Theme}}
<link rel="stylesheet" href="/apps/theming/css/default.css"> <link rel="stylesheet" href="/apps/theming/css/default.css">
@@ -219,7 +220,32 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
} }
body { margin: 0; font: 15px/1.4 var(--font-face, system-ui, sans-serif); } body { margin: 0; font: 15px/1.4 var(--font-face, system-ui, sans-serif); }
#app-nav { display: flex; flex-direction: column; min-height: 100vh; background: transparent; } #app-nav { display: flex; flex-direction: column; min-height: 100vh; background: transparent; }
#nav-header { padding: 8px 12px; border-bottom: 1px solid var(--color-border); } #nav-header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
}
#nav-header-text { flex: 1; min-width: 0; }
#nav-toggle {
display: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin: 0;
padding: 0;
border: 0;
flex: none;
background: transparent;
color: inherit;
cursor: pointer;
border-radius: var(--border-radius-element);
}
#nav-toggle:hover { background: var(--color-background-hover); }
#nav-toggle svg { width: 20px; height: 20px; }
#nav-backdrop { display: none; }
#app-nav-body { display: flex; flex: 1; min-height: 0; } #app-nav-body { display: flex; flex: 1; min-height: 0; }
nav.app-navigation { nav.app-navigation {
width: var(--navigation-width, 300px); width: var(--navigation-width, 300px);
@@ -294,14 +320,47 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
li[data-expanded="false"] > .app-navigation-entry__children { display: none; } li[data-expanded="false"] > .app-navigation-entry__children { display: none; }
li[data-expanded="false"] button.fold svg { transform: rotate(-90deg); } li[data-expanded="false"] button.fold svg { transform: rotate(-90deg); }
#nav-missing { margin: 0 0 1rem; } #nav-missing { margin: 0 0 1rem; }
@media (max-width: 1024px) {
#nav-header {
position: sticky;
top: 0;
z-index: 2100;
background-color: var(--color-main-background);
}
#nav-toggle { display: flex; }
nav.app-navigation {
display: none;
position: fixed;
z-index: 2000;
top: var(--nav-header-offset, 0px);
bottom: 0;
inset-inline-start: 0;
width: min(var(--navigation-width, 300px), 85vw);
}
body.nav-open nav.app-navigation { display: block; }
body.nav-open #nav-backdrop {
display: block;
position: fixed;
z-index: 1990;
top: var(--nav-header-offset, 0px);
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.45);
}
}
</style> </style>
</head> </head>
<body data-selected="{{.Selected}}" data-address="{{.Address}}"> <body data-selected="{{.Selected}}" data-address="{{.Address}}">
{{.Dialog}} {{.Dialog}}
<div id="app-nav"> <div id="app-nav">
<div id="nav-header">{{.Header}}</div> <div id="nav-header">
<button type="button" id="nav-toggle" aria-controls="app-navigation" aria-expanded="false" aria-label="Open navigation"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2z"/></svg></button>
<div id="nav-header-text">{{.Header}}</div>
</div>
<div id="nav-backdrop"></div>
<div id="app-nav-body"> <div id="app-nav-body">
<nav class="app-navigation" aria-label="App navigation"> <nav id="app-navigation" class="app-navigation" aria-label="App navigation">
<ul class="app-navigation-list">{{template "items" .Items}}</ul> <ul class="app-navigation-list">{{template "items" .Items}}</ul>
</nav> </nav>
<main> <main>
@@ -325,6 +384,36 @@ var shellTmpl = template.Must(template.New("shell").Parse(`<!DOCTYPE html>
btn.setAttribute("aria-expanded", open ? "false" : "true"); btn.setAttribute("aria-expanded", open ? "false" : "true");
}); });
}); });
var toggle = document.getElementById("nav-toggle");
var backdrop = document.getElementById("nav-backdrop");
function placeNav() {
var header = document.getElementById("nav-header");
if (!header) return;
document.documentElement.style.setProperty("--nav-header-offset", header.offsetHeight + "px");
}
function setNavOpen(open) {
placeNav();
document.body.classList.toggle("nav-open", open);
if (!toggle) return;
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? "Close navigation" : "Open navigation");
}
placeNav();
window.addEventListener("resize", placeNav);
var narrow = window.matchMedia("(max-width: 1024px)");
if (narrow.addEventListener) narrow.addEventListener("change", placeNav);
if (toggle) {
toggle.addEventListener("click", function () {
setNavOpen(!document.body.classList.contains("nav-open"));
});
}
if (backdrop) backdrop.addEventListener("click", function () { setNavOpen(false); });
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") return;
var dialog = document.getElementById("exapp-dialog");
if (dialog && dialog.open) return;
setNavOpen(false);
});
})(); })();
</script> </script>
</body> </body>
+67
View File
@@ -8,6 +8,9 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/chromedp/chromedp"
"github.com/chromedp/chromedp/kb"
"gitea.neitzel.de/konrad/go-nc-exapp" "gitea.neitzel.de/konrad/go-nc-exapp"
) )
@@ -201,3 +204,67 @@ func TestAppNavigationHeaderPrecedesTree(t *testing.T) {
t.Fatalf("header %d tree %d in %s", header, tree, body) t.Fatalf("header %d tree %d in %s", header, tree, body)
} }
} }
func TestAppNavigationOffersNarrowScreenToggle(t *testing.T) {
body := navBody(t, "/")
for _, need := range []string{
`<meta name="viewport" content="width=device-width, initial-scale=1">`,
`id="nav-toggle"`,
`aria-controls="app-navigation"`,
`aria-expanded="false"`,
`aria-label="Open navigation"`,
`id="nav-backdrop"`,
`id="app-navigation"`,
`max-width: 1024px`,
} {
if !strings.Contains(body, need) {
t.Errorf("missing %s", need)
}
}
}
// navLayoutJS reports how App navigation is laid out: "wide", "closed", or "open".
const navLayoutJS = `(() => {
const nav = document.querySelector("nav.app-navigation");
const btn = document.getElementById("nav-toggle");
const narrow = window.matchMedia("(max-width: 1024px)").matches;
const box = nav.getBoundingClientRect();
const navShown = getComputedStyle(nav).display !== "none" && box.width > 0 && box.left < window.innerWidth && box.right > 0;
const btnShown = getComputedStyle(btn).display !== "none";
const expanded = btn.getAttribute("aria-expanded") === "true";
if (!narrow && navShown && !btnShown && !expanded) return "wide";
if (narrow && !navShown && btnShown && !expanded) return "closed";
if (narrow && navShown && btnShown && expanded) return "open";
return "narrow=" + narrow + " nav=" + navShown + " btn=" + btnShown + " expanded=" + expanded + " width=" + window.innerWidth;
})()`
func TestAppNavigationWideScreenKeepsTheTreeVisible(t *testing.T) {
drive(t, "/nav",
chromedp.EmulateViewport(1280, 800),
waitJS(navLayoutJS, "wide", false),
)
}
func TestAppNavigationNarrowScreenOpensTheTreeOnDemand(t *testing.T) {
drive(t, "/nav",
chromedp.EmulateViewport(390, 800),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.MouseClickXY(370, 400),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.KeyEvent(kb.Escape),
waitJS(navLayoutJS, "closed", false),
chromedp.Click("#nav-toggle", chromedp.ByQuery),
waitJS(navLayoutJS, "open", false),
chromedp.Click(`a.app-navigation-entry-link[href*="item=keys"]`, chromedp.ByQuery),
waitJS(`document.querySelector("main").textContent`, "keys-page", true),
waitJS(navLayoutJS, "closed", false),
)
}