Animate navigation and sidebar controls
This commit is contained in:
parent
c72f5fed82
commit
624f62f25e
6 changed files with 98 additions and 19 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Single source of truth for the SharedDocsLib package version."""
|
||||
|
||||
__version__ = "0.3.9"
|
||||
__version__ = "0.4.0"
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue