Animate navigation and sidebar controls

This commit is contained in:
Server 2026-08-14 11:35:50 +00:00
parent c72f5fed82
commit 624f62f25e
6 changed files with 98 additions and 19 deletions

View file

@ -15,9 +15,9 @@ SharedDocsLib — небольшой Python-фреймворк для докум
UI не содержит языко-зависимых служебных подписей. Базовый шрифт — `system-ui 16px`, а служебные UTF-8-пиктограммы используют Arial. Навигационные стрелки отрисовываются CSS-границами без текстовых символов. AMOLED-тема включена по умолчанию и сохраняется локально после переключения.
На десктопе компактный header содержит полное название документации, поиск и переключатель темы, а слева постоянно видны header-кнопки и дерево страниц. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Проверка manifest выполняется автоматически в фоне без отдельной кнопки обновления.
На десктопе компактный header содержит toggle боковой панели, полное название документации, поиск и переключатель темы. Sidebar по умолчанию открыт, его состояние не сохраняется между загрузками. Поисковый placeholder медленно печатает первые слова случайного названия, добавляет троеточие только в конце и сохраняет готовый текст 5 секунд; при вводе поле раскрывает ранжированные результаты вниз. Проверка manifest выполняется автоматически в фоне без отдельной кнопки обновления.
На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. Тема и обновление на мобильном скрыты.
На мобильных устройствах используется viewport `100dvh` и нижний dock. Кнопка `☰` заменяет содержимое страницы копией desktop-sidebar, которая выезжает с левой границы экрана. Ввод в нижний поиск заменяет содержимое результатами, расположенными снизу вверх по релевантности. При переходе между страницами контент плавно смещается на 30 px по направлению навигации и меняет прозрачность; тема и обновление на мобильном скрыты.
## Установка из Git

View file

@ -1,3 +1,3 @@
"""Single source of truth for the SharedDocsLib package version."""
__version__ = "0.3.9"
__version__ = "0.4.0"

View file

@ -46,13 +46,17 @@ button { color: inherit; }
.app-shell { --desktop-sidebar-width: clamp(270px, 25vw, 400px); display: flex; width: 100%; height: 100%; flex-direction: column; overflow: hidden; background: var(--shell); }
.desktop-header { position: relative; z-index: 20; display: grid; min-height: 76px; flex: 0 0 76px; grid-template-columns: minmax(260px, 1fr) auto; align-items: center; gap: 18px; padding: 12px 20px; background: var(--shell); }
.desktop-title-group { display: flex; min-width: 0; align-items: center; gap: 12px; }
.desktop-title { overflow: hidden; margin: 0; font-size: 1.55rem; font-weight: 450; line-height: 1.1; letter-spacing: -.025em; text-overflow: ellipsis; white-space: nowrap; }
.desktop-sidebar-toggle { display: grid; width: 44px; height: 44px; flex: 0 0 44px; place-items: center; border-radius: 50%; color: var(--text); background: var(--control); font-size: 1.6rem; }
.desktop-sidebar-toggle:hover { background: var(--active); }
.header-link { display: block; width: 100%; padding: 6px 12px; border: 0; border-radius: 20px; color: var(--text); background: transparent; text-decoration: none; line-height: 1.2; transition: background .16s ease; }
.header-link:hover { background: var(--control); }
.header-link.active { color: var(--text); background: var(--active); }
.desktop-main { display: flex; min-width: 0; min-height: 0; flex: 1; overflow: hidden; }
.desktop-sidebar { display: flex; width: var(--desktop-sidebar-width); min-width: 0; height: 100%; flex: 0 0 var(--desktop-sidebar-width); flex-direction: column; overflow: hidden; padding: 0 18px; background: var(--shell); }
.desktop-sidebar { display: flex; width: var(--desktop-sidebar-width); min-width: 0; height: 100%; flex: 0 0 var(--desktop-sidebar-width); flex-direction: column; overflow: hidden; padding: 0 18px; background: var(--shell); opacity: 1; transition: width .24s ease, flex-basis .24s ease, padding .24s ease, opacity .16s ease; }
body.desktop-sidebar-collapsed .desktop-sidebar { width: 0; flex-basis: 0; padding-right: 0; padding-left: 0; opacity: 0; pointer-events: none; }
.sidebar-header-buttons { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-bottom: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); }
.sidebar-header-buttons .header-link { color: var(--text); }
.sidebar-header-buttons .header-link:hover { background: var(--control); }
@ -157,6 +161,7 @@ button { color: inherit; }
body.mobile-menu-open .mobile-menu-view { display: block; }
body.mobile-search-open .mobile-search-view { display: flex; }
.mobile-menu-view { padding: 52px 28px calc(150px + env(safe-area-inset-bottom)); }
body.mobile-menu-open .mobile-menu-view { animation: mobile-menu-enter .26s cubic-bezier(.2, .8, .2, 1) both; }
.mobile-site-title { margin: 0 0 34px; font-size: clamp(2.4rem, 11vw, 4rem); font-weight: 400; line-height: 1.1; letter-spacing: -.035em; overflow-wrap: anywhere; }
.mobile-header-buttons { display: flex; flex-direction: column; gap: 6px; margin-bottom: 38px; border: 1px solid var(--border); border-radius: 24px; background: var(--surface); }
.mobile-header-buttons .header-link { padding: 12px 18px; border-radius: 28px; font-size: 1.05rem; }
@ -177,6 +182,11 @@ button { color: inherit; }
.mobile-search-control .search-glyph { width: 64px; height: 64px; flex-basis: 64px; background: transparent; font-size: 2.3rem; }
}
@keyframes mobile-menu-enter {
from { opacity: 0; transform: translateX(-100%); }
to { opacity: 1; transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}

View file

@ -7,6 +7,7 @@
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)");
const elements = {
desktopTitle: document.getElementById("desktop-site-title"),
desktopSidebarToggle: document.getElementById("desktop-sidebar-toggle"),
mobileTitle: document.getElementById("mobile-site-title"),
desktopHeaderButtons: document.getElementById("desktop-header-buttons"),
mobileHeaderButtons: document.getElementById("mobile-header-buttons"),
@ -44,6 +45,8 @@
let lastPlaceholderPage = -1;
let customScriptLoaded = false;
let lastSyncedRevision = String(manifest.revision || "");
let pageTransitionId = 0;
let pageAnimation = null;
const dbPromise = new Promise((resolve, reject) => {
if (!window.indexedDB) return reject(new Error("IndexedDB"));
@ -135,6 +138,11 @@
document.body.classList.toggle("mobile-search-open", mode === "search");
}
function setDesktopSidebar(open) {
document.body.classList.toggle("desktop-sidebar-collapsed", !open);
elements.desktopSidebarToggle.setAttribute("aria-expanded", String(open));
}
function closeSearch({ clear = false } = {}) {
elements.desktopSearchShell.classList.remove("open");
if (clear) {
@ -146,11 +154,13 @@
function navigate(number, replace = false) {
const hash = `#/page/${encodeURIComponent(number)}`;
if (replace) history.replaceState(null, "", hash);
else if (location.hash !== hash) location.hash = hash;
closeSearch({ clear: true });
setMobileMode("content");
renderPage(number);
if (replace) {
history.replaceState(null, "", hash);
renderPage(number);
} else if (location.hash !== hash) location.hash = hash;
else renderPage(number);
}
function buildTree(container) {
@ -342,15 +352,7 @@
button.onclick = () => navigate(page.number);
}
function renderPage(number) {
if (!manifest.pages.length) {
elements.content.replaceChildren();
elements.previous.disabled = elements.next.disabled = true;
return;
}
let index = manifest.pages.findIndex((page) => page.number === number);
if (index < 0) index = 0;
const page = manifest.pages[index];
function applyPage(page, index) {
currentNumber = page.number;
if (routeNumber() !== page.number) history.replaceState(null, "", `#/page/${encodeURIComponent(page.number)}`);
document.title = `${page.title} · ${manifest.title}`;
@ -369,6 +371,53 @@
elements.pageSurface.scrollTo({ top: 0, behavior: "instant" });
}
async function renderPage(number) {
if (!manifest.pages.length) {
elements.content.replaceChildren();
elements.previous.disabled = elements.next.disabled = true;
return;
}
let index = manifest.pages.findIndex((page) => page.number === number);
if (index < 0) index = 0;
const page = manifest.pages[index];
if (page.number === currentNumber && elements.content.childNodes.length) return;
const currentIndex = manifest.pages.findIndex((candidate) => candidate.number === currentNumber);
const direction = currentIndex >= 0 && index < currentIndex ? -1 : 1;
const animate = currentIndex >= 0 && !reducedMotion.matches && typeof elements.content.animate === "function";
const transitionId = ++pageTransitionId;
pageAnimation?.cancel();
pageAnimation = null;
if (animate) {
const outgoing = elements.content.animate(
[
{ opacity: 1, transform: "translateX(0)" },
{ opacity: 0, transform: `translateX(${-30 * direction}px)` },
],
{ duration: 150, easing: "ease-in", fill: "forwards" },
);
pageAnimation = outgoing;
await outgoing.finished.catch(() => {});
if (transitionId !== pageTransitionId) return;
outgoing.cancel();
}
applyPage(page, index);
if (!animate || transitionId !== pageTransitionId) return;
const incoming = elements.content.animate(
[
{ opacity: 0, transform: `translateX(${30 * direction}px)` },
{ opacity: 1, transform: "translateX(0)" },
],
{ duration: 210, easing: "ease-out" },
);
pageAnimation = incoming;
incoming.finished.catch(() => {}).finally(() => {
if (pageAnimation === incoming) pageAnimation = null;
});
}
function plainText(html) {
const node = document.createElement("div");
node.innerHTML = html;
@ -652,6 +701,9 @@
elements.mobileSearchResults.replaceChildren();
setMobileMode(mobileMode === "menu" ? "content" : "menu");
});
elements.desktopSidebarToggle.addEventListener("click", () => {
setDesktopSidebar(document.body.classList.contains("desktop-sidebar-collapsed"));
});
document.getElementById("theme-button").addEventListener("click", () => {
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
});

View file

@ -22,7 +22,10 @@
<div id="docslib-loading" aria-hidden="true"><div class="docslib-loading-spinner"></div></div>
<div id="app" class="app-shell">
<header class="desktop-header">
<h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2>
<div class="desktop-title-group">
<button id="desktop-sidebar-toggle" class="desktop-sidebar-toggle glyph-button" aria-label="☰" aria-expanded="true"></button>
<h2 id="desktop-site-title" class="desktop-title">{{DOCSLIB_TITLE}}</h2>
</div>
<div id="desktop-toolbar" class="desktop-toolbar">
<div id="desktop-search-shell" class="desktop-search-shell">
<div class="search-control">

View file

@ -97,7 +97,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
assert html_response.status_code == 200
assert "http://127.0.0.1:9000/manifest.json" in html
assert "http://127.0.0.1:9000/version" in html
assert 'globalThis.COMPILED_FROM_VERSION = "0.3.9";' in html
assert 'globalThis.COMPILED_FROM_VERSION = "0.4.0";' in html
assert ".content { --custom-test: yes; }" in html
assert r'document.body.dataset.customTest = \"yes\";' in html
assert 'id="docslib-loading"' in html
@ -177,6 +177,8 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
assert "--shell: #000000" in html
assert 'id="desktop-search-input"' in html
assert '<header class="desktop-header">' in html
assert 'id="desktop-sidebar-toggle"' in html
assert 'aria-label="" aria-expanded="true">☰</button>' in html
assert '<aside class="desktop-sidebar">' in html
assert 'class="sidebar-header-buttons"' in html
assert 'id="mobile-search-input"' in html
@ -203,6 +205,18 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
assert "background: transparent; font-size: 2.3rem" in html
assert ".mobile-page-tree .tree-link { min-height: 46px; padding: 9px 14px; border-radius: 24px" in html
assert ".mobile-menu-button { width: 72px; height: 72px; flex: 0 0 72px; border-radius: 50%; color: var(--text); background: var(--control); font-size: 1.6rem; }" in html
assert ".desktop-sidebar-toggle { display: grid; width: 44px; height: 44px; flex: 0 0 44px" in html
assert "body.desktop-sidebar-collapsed .desktop-sidebar { width: 0; flex-basis: 0;" in html
assert 'elements.desktopSidebarToggle.addEventListener("click"' in system_js
assert 'document.body.classList.toggle("desktop-sidebar-collapsed", !open)' in system_js
assert "localStorage.setItem" not in system_js[system_js.index("function setDesktopSidebar"):system_js.index("function closeSearch")]
assert "animation: mobile-menu-enter .26s" in html
assert "transform: translateX(-100%)" in html
assert "const direction = currentIndex >= 0 && index < currentIndex ? -1 : 1" in system_js
assert 'transform: `translateX(${-30 * direction}px)`' in system_js
assert 'transform: `translateX(${30 * direction}px)`' in system_js
assert 'opacity: 0' in system_js
assert "!reducedMotion.matches" in system_js
assert ".nav-card { display: flex; min-height: 48px; align-items: center; gap: 8px; padding: 7px 20px; border: 0; border-radius: 30px" in html
assert "background: linear-gradient(0deg, var(--shell) 0%, transparent)" in html
assert "<script src=" not in html
@ -218,7 +232,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
def test_build_writes_static_bundle(tmp_path):
assert __version__ == "0.3.9"
assert __version__ == "0.4.0"
@Page("1", "Static page")
def static_page():