diff --git a/webapp/index.html b/webapp/index.html index 95b7f1f..574c557 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -21,7 +21,7 @@
-Доступ запрещён.
diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js index 357e6d4..1ab3915 100644 --- a/webapp/js/dayDetail.js +++ b/webapp/js/dayDetail.js @@ -166,9 +166,12 @@ function positionPopover(panel, cellRect) { /** * Show panel as bottom sheet (fixed at bottom). + * Renders panel closed (translateY(100%)), then on next frame adds open/visible classes for animation. */ function showAsBottomSheet() { if (!panelEl || !overlayEl) return; + overlayEl.classList.remove("day-detail-overlay--visible"); + panelEl.classList.remove("day-detail-panel--open"); overlayEl.hidden = false; panelEl.classList.add("day-detail-panel--sheet"); panelEl.style.left = ""; @@ -181,8 +184,14 @@ function showAsBottomSheet() { document.body.classList.add("day-detail-sheet-open"); document.body.style.top = "-" + sheetScrollY + "px"; - const closeBtn = panelEl.querySelector(".day-detail-close"); - if (closeBtn) closeBtn.focus(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + overlayEl.classList.add("day-detail-overlay--visible"); + panelEl.classList.add("day-detail-panel--open"); + const closeBtn = panelEl.querySelector(".day-detail-close"); + if (closeBtn) closeBtn.focus(); + }); + }); } /** @@ -205,14 +214,48 @@ function showAsPopover(cellRect) { setTimeout(() => document.addEventListener("click", popoverCloseHandler), 0); } +/** Timeout fallback (ms) if transitionend does not fire (e.g. prefers-reduced-motion). */ +const SHEET_CLOSE_TIMEOUT_MS = 400; + /** - * Hide panel and overlay. + * Hide panel and overlay. For bottom sheet: remove open classes, wait for transitionend (or timeout), then cleanup. */ export function hideDayDetail() { if (popoverCloseHandler) { document.removeEventListener("click", popoverCloseHandler); popoverCloseHandler = null; } + const isSheet = + panelEl && + overlayEl && + panelEl.classList.contains("day-detail-panel--sheet") && + document.body.classList.contains("day-detail-sheet-open"); + + if (isSheet) { + panelEl.classList.remove("day-detail-panel--open"); + overlayEl.classList.remove("day-detail-overlay--visible"); + + const finishClose = () => { + if (closeTimeoutId != null) clearTimeout(closeTimeoutId); + panelEl.removeEventListener("transitionend", onTransitionEnd); + document.body.classList.remove("day-detail-sheet-open"); + document.body.style.top = ""; + window.scrollTo(0, sheetScrollY); + panelEl.classList.remove("day-detail-panel--sheet"); + panelEl.hidden = true; + overlayEl.hidden = true; + }; + + let closeTimeoutId = setTimeout(finishClose, SHEET_CLOSE_TIMEOUT_MS); + + const onTransitionEnd = (e) => { + if (e.target !== panelEl || e.propertyName !== "transform") return; + finishClose(); + }; + panelEl.addEventListener("transitionend", onTransitionEnd); + return; + } + if (document.body.classList.contains("day-detail-sheet-open")) { document.body.classList.remove("day-detail-sheet-open"); document.body.style.top = ""; diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js index 9c0a3cf..66c2dbb 100644 --- a/webapp/js/dutyList.js +++ b/webapp/js/dutyList.js @@ -168,10 +168,10 @@ export function renderDutyList(duties) { const calendarHeight = calendarSticky.offsetHeight; const top = el.getBoundingClientRect().top + window.scrollY; const scrollTop = Math.max(0, top - calendarHeight); - window.scrollTo({ top: scrollTop, behavior: "auto" }); + window.scrollTo({ top: scrollTop, behavior: "smooth" }); }); } else { - el.scrollIntoView({ behavior: "auto", block: "start" }); + el.scrollIntoView({ behavior: "smooth", block: "start" }); } }; const currentDutyCard = dutyListEl.querySelector(".duty-item--current"); diff --git a/webapp/js/hints.js b/webapp/js/hints.js index 64783b3..8cfec74 100644 --- a/webapp/js/hints.js +++ b/webapp/js/hints.js @@ -16,6 +16,7 @@ export function positionHint(hintEl, btnRect) { const vw = document.documentElement.clientWidth; const margin = 12; hintEl.classList.remove("below"); + hintEl.classList.remove("calendar-event-hint--visible"); hintEl.style.left = btnRect.left + "px"; hintEl.style.top = btnRect.top - 4 + "px"; hintEl.hidden = false; @@ -45,6 +46,9 @@ export function positionHint(hintEl, btnRect) { hintEl.style.left = left2 + "px"; }); } + requestAnimationFrame(() => { + hintEl.classList.add("calendar-event-hint--visible"); + }); }); } @@ -268,8 +272,11 @@ export function bindInfoButtonTooltips() { positionHint(hintEl, rect); hintEl.dataset.active = "1"; } else { - hintEl.hidden = true; - hintEl.removeAttribute("data-active"); + hintEl.classList.remove("calendar-event-hint--visible"); + setTimeout(() => { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + }, 150); } }); }); @@ -277,14 +284,20 @@ export function bindInfoButtonTooltips() { document._calendarHintBound = true; document.addEventListener("click", () => { if (hintEl.dataset.active) { - hintEl.hidden = true; - hintEl.removeAttribute("data-active"); + hintEl.classList.remove("calendar-event-hint--visible"); + setTimeout(() => { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + }, 150); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && hintEl.dataset.active) { - hintEl.hidden = true; - hintEl.removeAttribute("data-active"); + hintEl.classList.remove("calendar-event-hint--visible"); + setTimeout(() => { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + }, 150); } }); } @@ -324,6 +337,7 @@ export function bindDutyMarkerTooltips() { }); marker.addEventListener("mouseleave", () => { if (hintEl.dataset.active) return; + hintEl.classList.remove("calendar-event-hint--visible"); hideTimeout = setTimeout(() => { hintEl.hidden = true; hideTimeout = null; @@ -332,8 +346,11 @@ export function bindDutyMarkerTooltips() { marker.addEventListener("click", (e) => { e.stopPropagation(); if (marker.classList.contains("calendar-marker-active")) { - hintEl.hidden = true; - hintEl.removeAttribute("data-active"); + hintEl.classList.remove("calendar-event-hint--visible"); + setTimeout(() => { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + }, 150); marker.classList.remove("calendar-marker-active"); return; } @@ -355,16 +372,22 @@ export function bindDutyMarkerTooltips() { document._dutyMarkerHintBound = true; document.addEventListener("click", () => { if (hintEl.dataset.active) { - hintEl.hidden = true; - hintEl.removeAttribute("data-active"); - clearActiveDutyMarker(); + hintEl.classList.remove("calendar-event-hint--visible"); + setTimeout(() => { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + clearActiveDutyMarker(); + }, 150); } }); document.addEventListener("keydown", (e) => { if (e.key === "Escape" && hintEl.dataset.active) { - hintEl.hidden = true; - hintEl.removeAttribute("data-active"); - clearActiveDutyMarker(); + hintEl.classList.remove("calendar-event-hint--visible"); + setTimeout(() => { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + clearActiveDutyMarker(); + }, 150); } }); } diff --git a/webapp/js/main.js b/webapp/js/main.js index 3a7b351..ba3805f 100644 --- a/webapp/js/main.js +++ b/webapp/js/main.js @@ -38,7 +38,8 @@ initTheme(); state.lang = getLang(); document.documentElement.lang = state.lang; document.title = t(state.lang, "app.title"); -if (loadingEl) loadingEl.textContent = t(state.lang, "loading"); +const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null; +if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading"); const dayLabels = weekdayLabels(state.lang); if (weekdaysEl) { const spans = weekdaysEl.querySelectorAll("span"); diff --git a/webapp/style.css b/webapp/style.css index e82ac7c..73c13e9 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -12,6 +12,19 @@ --error: #f7768e; --timeline-date-width: 3.6em; --timeline-track-width: 10px; + --transition-fast: 0.15s; + --transition-normal: 0.25s; + --ease-out: cubic-bezier(0.32, 0.72, 0, 1); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } /* Light theme: prefer Telegram themeParams (--tg-theme-*), fallback to Telegram-like palette */ @@ -102,10 +115,25 @@ body { font-size: 24px; line-height: 1; cursor: pointer; + transition: opacity var(--transition-fast), transform var(--transition-fast); +} + +.nav:focus { + outline: none; +} + +.nav:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .nav:active { - opacity: 0.8; + transform: scale(0.95); +} + +.nav:disabled { + opacity: 0.5; + cursor: not-allowed; } .title { @@ -132,6 +160,15 @@ body { padding-bottom: 12px; margin-bottom: 4px; touch-action: pan-y; + transition: box-shadow var(--transition-fast) ease-out; +} + +.calendar-sticky.is-scrolled { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] .calendar-sticky.is-scrolled { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); } /* === Calendar grid & day cells */ @@ -156,6 +193,7 @@ body { min-width: 0; min-height: 0; overflow: hidden; + transition: background-color var(--transition-fast), transform var(--transition-fast); } .day.other-month { @@ -187,6 +225,19 @@ body { cursor: pointer; } +.day:focus { + outline: none; +} + +.day:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.day:active { + transform: scale(0.98); +} + .day-indicator { display: flex; flex-wrap: wrap; @@ -247,6 +298,14 @@ body.day-detail-sheet-open { z-index: 999; background: rgba(0, 0, 0, 0.4); -webkit-tap-highlight-color: transparent; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-normal) ease-out; +} + +.day-detail-overlay.day-detail-overlay--visible { + opacity: 1; + pointer-events: auto; } .day-detail-panel { @@ -277,6 +336,12 @@ body.day-detail-sheet-open { padding-right: 16px; /* Комфортный отступ снизу: safe area + дополнительное поле */ padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px)); + transform: translateY(100%); + transition: transform var(--transition-normal) var(--ease-out); +} + +.day-detail-panel--sheet.day-detail-panel--open { + transform: translateY(0); } .day-detail-panel--sheet::before { @@ -303,6 +368,16 @@ body.day-detail-sheet-open { line-height: 1; cursor: pointer; border-radius: 8px; + transition: opacity var(--transition-fast), background-color var(--transition-fast); +} + +.day-detail-close:focus { + outline: none; +} + +.day-detail-close:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .day-detail-close:hover { @@ -403,6 +478,15 @@ body.day-detail-sheet-open { white-space: pre; overflow: visible; transform: translateY(-100%); + transition: opacity 0.15s ease-out, transform 0.15s ease-out; +} + +.calendar-event-hint:not(.calendar-event-hint--visible) { + opacity: 0; +} + +.calendar-event-hint.calendar-event-hint--visible { + opacity: 1; } .calendar-event-hint.below { @@ -464,6 +548,7 @@ body.day-detail-sheet-open { border-radius: 50%; flex-shrink: 0; cursor: pointer; + transition: box-shadow var(--transition-fast) ease-out; } .duty-marker { @@ -723,15 +808,71 @@ body.day-detail-sheet-open { .duty-item--current { border-left-color: var(--today); background: color-mix(in srgb, var(--today) 12%, var(--surface)); + animation: duty-current-pulse 2s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .duty-item--current { + animation: none; + } +} + +@keyframes duty-current-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 color-mix(in srgb, var(--today) 30%, transparent); + } + 50% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--today) 15%, transparent); + } } /* === Loading / error / access denied */ +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 12px; + color: var(--muted); + text-align: center; +} + +.loading__spinner { + display: block; + width: 20px; + height: 20px; + border: 2px solid transparent; + border-top-color: var(--accent); + border-radius: 50%; + animation: loading-spin 0.8s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .loading__spinner { + animation: none; + border-top-color: var(--accent); + border-right-color: color-mix(in srgb, var(--accent) 50%, transparent); + } +} + +@keyframes loading-spin { + to { + transform: rotate(360deg); + } +} + .loading, .error { text-align: center; padding: 12px; color: var(--muted); } +.error, +.access-denied { + transition: opacity 0.2s ease-out; +} + .error { color: var(--error); }