摘要
讓操作「看起來就對」。本日替 showPage() 加入過場動畫(淡入/淡出、滑入)、任務卡拖曳時的高亮與壓下感,以及主要按鈕的 Ripple 波紋與按壓縮放。同時尊重使用者的 prefers-reduced-motion,在需要時自動關閉動畫。
假設已具備:
/* ========== [Day28-NEW] 動畫變數與減動態支援 ========== */
:root {
--anim-fast: .18s;
--anim-normal: .32s;
--easing: cubic-bezier(.22,.61,.36,1); /* 柔和、收尾順 */
}
/* 使用者要求「降低動態」時:關閉動畫/過場 */
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* ========== [Day28-NEW] 頁面過場 ========== */
/* 進場:微下位移 + 淡入;退場:微上位移 + 淡出 */
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeSlideOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-6px); }
}
.anim-enter { animation: fadeSlideIn var(--anim-normal) var(--easing) both; }
.anim-leave { animation: fadeSlideOut var(--anim-normal) var(--easing) both; }
/* 避免 display:none 阻斷動畫:過場時暫時確保可見且不搶點擊 */
.page-is-switching { pointer-events: none; }
/* ========== [Day28-NEW] 任務卡拖曳中的視覺強化 ========== */
#taskCard.dragging {
opacity: .92;
transform: scale(.98);
box-shadow: 0 8px 22px rgba(0,0,0,.15);
transition: transform var(--anim-fast) var(--easing), box-shadow var(--anim-fast) var(--easing), opacity var(--anim-fast);
}
/* 已有 .time-slot.dropping:再加一點入場感與邊緣光暈 */
.time-slot.dropping {
transform: translateY(-1px) scale(1.01);
transition: transform var(--anim-fast) var(--easing), box-shadow var(--anim-fast) var(--easing);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), 0 6px 18px rgba(0,0,0,.06);
}
/* ========== [Day28-NEW] 按鈕按壓與 Ripple ========== */
button {
transition: transform .06s ease, background-color .2s ease, box-shadow .2s ease;
}
button:active { transform: scale(.985); }
/* 開啟 ripple 的按鈕需要相對定位與裁切 */
.ripple-btn { position: relative; overflow: hidden; }
.ripple-btn::after {
content: "";
position: absolute;
left: var(--rx, 50%); top: var(--ry, 50%);
width: 0; height: 0; border-radius: 999px;
transform: translate(-50%, -50%);
background: rgba(255,255,255,.35); /* 在淺底會視覺較淡,主要用於主色按鈕 */
pointer-events: none;
opacity: 0;
}
.rippling::after { animation: ripple .48s ease-out forwards; }
@keyframes ripple {
0% { width: 0; height: 0; opacity: .55; }
100% { width: 180px; height: 180px; opacity: 0; }
}
/* 深色模式下 ripple 用較暗的亮點,避免過亮刺眼 */
body.dark .ripple-btn::after { background: rgba(255,255,255,.28); }
備註:prefers-reduced-motion 會讓整站 transition/animation 關閉;可確保敏感使用者舒適。
// [Day28-NEW] 任務卡拖曳中的壓下感
document.addEventListener('dragstart', (e) => {
const el = e.target;
if (el && el.id === 'taskCard') {
el.classList.add('dragging');
}
});
document.addEventListener('dragend', (e) => {
const el = e.target;
if (el && el.id === 'taskCard') {
el.classList.remove('dragging');
}
});
// [Day28-NEW] slot 加強 dragenter / dragleave(已含 dragover/drop,這裡補更流暢的亮邊控制)
[morningSlotEl, afternoonSlotEl, eveningSlotEl].forEach(slot => {
if (!slot) return;
slot.addEventListener('dragenter', () => slot.classList.add('dropping'));
slot.addEventListener('dragleave', () => slot.classList.remove('dropping'));
});
2-2. Ripple 微互動:主要行動按鈕掛載
// [Day28-NEW] Ripple:將主要按鈕升級為 .ripple-btn
function attachRipple(btn) {
if (!btn) return;
btn.classList.add('ripple-btn');
btn.addEventListener('click', (e) => {
// 若使用者偏好減少動態 → 跳過
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const rect = btn.getBoundingClientRect();
const x = (e.clientX || (rect.left + rect.width/2)) - rect.left;
const y = (e.clientY || (rect.top + rect.height/2)) - rect.top;
btn.style.setProperty('--rx', `${x}px`);
btn.style.setProperty('--ry', `${y}px`);
btn.classList.remove('rippling'); // 允許連點重啟動畫
// 強制 reflow 以重啟動畫
void btn.offsetWidth;
btn.classList.add('rippling');
setTimeout(() => btn.classList.remove('rippling'), 500);
});
}
function enhanceMicroInteractions() {
// 針對主色行動鈕加 ripple(你也可以視覺需求擴充)
[
'#submitBtn', '#btnStartNow', '#btnShare',
'#btnBackupNow', '#btnExportJSON', '#btnExportCSV', '#btnExportQuoteJSON'
].forEach(sel => attachRipple(document.querySelector(sel)));
}
放在你的 init() 流程內呼叫(如下 2-4 節)。
2-3. 頁面切換動畫版:enter/leave 過場(尊重 reduced-motion)
// [Day28-NEW] 找出目前顯示中的主要區塊
function getVisibleSection() {
const sections = [formSection, historySection, moodTestSection, moodDecisionSection, taskScheduleSection].filter(Boolean);
return sections.find(s => s.hidden === false) || null;
}
// [Day28-NEW] 以動畫切換 section(leave -> enter),動畫結束後才調整 hidden
function switchSectionWithAnimation(fromEl, toEl, afterShowCb) {
// 無目標或同一個 → 略過
if (!toEl || fromEl === toEl) {
if (toEl) { toEl.hidden = false; typeof afterShowCb === 'function' && afterShowCb(); }
return;
}
const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
// 直接切換(無動畫)
if (fromEl) fromEl.hidden = true;
toEl.hidden = false;
typeof afterShowCb === 'function' && afterShowCb();
return;
}
// 動畫:先把目標顯示出來(以便動畫跑),再加 enter;來源加 leave,動畫結束後才 hidden
document.body.classList.add('page-is-switching');
if (fromEl) {
fromEl.classList.add('anim-leave');
fromEl.addEventListener('animationend', () => {
fromEl.classList.remove('anim-leave');
fromEl.hidden = true;
}, { once: true });
}
toEl.hidden = false;
toEl.classList.add('anim-enter');
toEl.addEventListener('animationend', () => {
toEl.classList.remove('anim-enter');
document.body.classList.remove('page-is-switching');
typeof afterShowCb === 'function' && afterShowCb();
}, { once: true });
}
2-4. 修改 showPage():改成使用動畫切換
(保留你原本的功能:進歷史頁會渲染統計與語錄牆、聚焦處理…)
// 小工具:嘗試聚焦標題;若失敗,將區塊設為可聚焦並聚焦
function focusOrFallback(sectionEl, titleElId) {
const titleEl = titleElId ? document.getElementById(titleElId) : null;
if (titleEl && typeof titleEl.focus === 'function') {
titleEl.focus();
} else if (sectionEl) {
sectionEl.setAttribute('tabindex', '-1');
sectionEl.focus && sectionEl.focus();
}
}
// 交給動畫函式統一處理
function showPage(page) {
const pages = {
home: document.getElementById('recordFormSection'),
history: document.getElementById('historySection'),
moodTest: document.getElementById('moodTestSection'),
moodDecision: document.getElementById('moodDecisionSection'),
taskSchedule: document.getElementById('taskScheduleSection')
};
const toEl = pages[page] || pages.home;
const fromEl = getVisibleSection(); // ← 先在任何 hidden 變動前抓目前可見頁
// 切換前:把將不顯示的先設 hidden(交由動畫函式處理)
Object.values(pages).forEach(sec => { if (sec && sec !== toEl) {/* 延後 hidden 到動畫事件中處理 */} });
// 切換後要做的事情(渲染、聚焦)
const afterShow = () => {
// 顯示後再做渲染與聚焦(避免還在動畫中就操作 DOM)
if (toEl === pages.history) {
renderMoodChartsAll().finally(() => {
typeof renderHistory === 'function' && renderHistory();
typeof renderQuoteWall === 'function' && renderQuoteWall();
});
const h1 = document.getElementById('history-title'); h1 ? h1.focus() : focusOrFallback(toEl);
} else if (toEl === pages.moodTest) {
focusOrFallback(toEl, 'mood-title');
} else if (toEl === pages.moodDecision) {
(document.getElementById('btnStartNow') || toEl).focus?.();
} else if (toEl === pages.taskSchedule) {
renderTaskCard();
(document.getElementById('taskCard') || document.getElementById('morningSlot') || toEl).focus?.();
} else {
// home
if (typeof focusFirstField === 'function') focusFirstField();
else focusOrFallback(toEl);
}
};
switchSectionWithAnimation(fromEl, toEl, afterShow);
}
2-5. 在 init() 內呼叫微互動增強
// [Day28-CHANGED] 初次載入:加入微互動增強
(function init(){
showIntro();
if (sortSelect) sortSelect.value = currentSort;
if (filterSelect) filterSelect.value = currentFilter;
// ★ 新增:掛上 Ripple 等微互動
enhanceMicroInteractions();
})();
為什麼動畫沒跑?
Ripple 沒出現:
拖曳沒反應:
轉場抖動/閃爍:
動畫卡住導致無法點擊:
檢查 .page-is-switching 是否未移除,或觀察是否有未觸發的 animationend(可用備援 timeout)。