標籤
黑色卡片上的白色 Astro 火箭標誌與字樣,火焰為粉紅色

讓 Astro 目錄記住你讀到哪裡 — 捲動監看、共用軌線,以及手機上的 dvh

為 Astro 部落格的目錄加上跟隨讀者的捲動、跨層級共用的軌線,以及在手機上悄悄吃掉 max-height 的 ::details-content 虛擬元素。

本頁目錄

引言

我在讀自己寫的一篇文章,往下讀了十幾個章節,抬眼看了一下目錄,想確認自己讀到哪裡。它顯示的是文章的開頭。這個頁面上唯一一個全部職責就是告訴你人在哪裡的元件,早就默默停止做這件事了,而我一直沒發現,因為我從來不會把自己的文章從頭讀到尾。

我第一個猜測是清單長到溢出了。它並沒有溢出。 它從存在的第一天起就是可以捲動的。它從來沒做過的事,是捲動它自己,於是標記我位置的那個高亮,就停在一個從我坐的地方看過去像是死掉的欄位裡、可見範圍之外的某處。

本文會走過一份目錄若要讓讀者不迷路,實際上需要哪些東西:跟隨讀者但不劫持頁面、標記作用中的項目並讓這個標記不靠顏色也活得下來,以及替手機版加上高度上限,讓它不會跑到螢幕外面去。其中兩項的成因,跟它們表面上看起來的樣子完全不一樣。

不迷路需要哪些條件

四個條件,而且只有第一個是顯而易見的。

條件 為什麼它不會自動成立
作用中的項目要保持可見 捲動監看只負責設一個 class。沒有任何東西會去捲動清單把它露出來。
這個標記不靠顏色也要活得下來 換個顏色對無法使用顏色的讀者是隱形的,對看得見顏色的人來說也很安靜。
清單要塞得進它所在的螢幕 一張沒有高度上限的固定卡片,會長到超出可視區域。
不要有東西來搶這一欄 側邊欄裡的垂直空間才是稀缺資源,而一個標題會永久花掉它。

這個網站一個都沒滿足,而我原本以為前三個是有的。

側邊欄本來就會捲動

桌機版的目錄住在文章旁邊的一個 aside 裡。它本來就同時具備了捲動容器的兩個要件:

src/layouts/BaseLayout.astro
.page[data-has-toc] .toc {
position: sticky;
top: var(--nav-height);
max-height: calc(100vh - var(--nav-height));
overflow-y: auto;
}

所以這一欄是會捲動的。缺的是任何一個會去移動它的東西。在原始碼裡搜尋那兩個可能派得上用場的 API,結果是 src/ 底下沒有任何 scrollIntoView,也沒有任何一處 scrollTop 賦值。捲動監看解析出作用中的標題,在對的連結上設好 aria-current="true",然後就停在那裡了。

那就是這個 bug。一個只會標記的捲動監看,運作得完美無缺,而且幫不上任何人的忙,因為在任何短到塞得進可視區域的清單上,它和一個也會捲動的版本毫無分別;而每一份長到真的需要幫忙的清單,都是那個標記早已跑到畫面外的清單。

跟隨讀者,但不移動文章

這個修法是智能體提出來的,我採用了:只有當作用中的項目離開一段從捲動容器上下緣內縮出來的區間時,才去移動側邊欄。

只有當作用中的項目離開區間時,側邊欄才會捲動三個相同的長條目錄欄並列。每一欄內部都標出一段區間,距離上緣 24 像素、距離下緣 48 像素。第一欄中,作用中的項目位於區間內,欄位不會移動。第二欄的項目高於區間,箭頭顯示它回到區間上緣。第三欄的項目低於區間,箭頭顯示它回到下緣。即使需要移動,欄位也只捲動到最近的邊緣為止。目錄欄會捲動自己的捲動區域項目在區間內作用中的項目不捲動項目高於區間上緣作用中的項目回到上緣項目低於區間下緣作用中的項目回到下緣24px48px區間只有當項目離開區間,欄位才會捲動
只有當作用中的項目離開區間時,才會去追它。在區間裡面,這一欄就停在讀者放它的地方。

另一個選項是每次變動都把作用中的項目重新置中。那更好預測,而且它永遠不會停下來,於是把一個導覽輔助變成了一個讓人手停不下來的小玩意。最近邊緣的做法,讓一個長章節被閱讀的期間欄位保持不動,而在項目位於區間內時回傳零,正是讓這個功能不去跟一位手動捲過清單的讀者對著幹的關鍵。

src/components/TableOfContents.astro
const top = port.top + 24;
const bottom = port.bottom - 48;
const above = box.top - top;
const below = box.bottom - bottom;
const delta = above < 0 ? above : below > 0 ? below : 0;
if (delta) scroller.scrollBy({ top: delta, behavior });

有兩個平台細節對這段程式碼的影響,比設計本身還大。

scrollIntoView 不能用。 最直覺的呼叫是 link.scrollIntoView({ block: 'nearest' }),而它會沿路往上走過每一個可捲動的祖先,包含文件本身。呼叫它,就等於為了整理側邊欄而把文章從讀者腳下捲走。只驅動那一個元素自己的 scrollBy,是唯一能保證頁面不會動的做法。

behavior 這個引數非傳不可,而理由跟被寫進註解裡的那個剛好相反。 智能體寫的是:html 上全域的 scroll-behavior: smooth 會讓側邊欄不管我們有沒有要求都做動畫,所以這個引數的存在,是為了在 prefers-reduced-motion 之下強制瞬間捲動。一位校閱者提出了疑問,而到瀏覽器裡確認,一行就結案了:

html scroll-behavior: smooth
aside scroll-behavior: auto

scroll-behavior 不是一個會繼承的屬性。 html 上的那條規則從來沒有抵達 aside 的捲動容器,那個容器算出來的是初始值 auto。明確傳入 behavior 依然是必要的,但原因是預設會用跳的,而不是它會做動畫。這段程式碼是誤打誤撞寫對的,而被記下來的理由是反的;兩者之中,後者留著不管的代價高得多。

一條軌線服務所有標題層級

作用中的項目原本只用一個顏色變化來標記,除此之外什麼都沒有。單靠顏色既是 WCAG 1.4.1 的問題,在一份二十個項目的清單裡,也單純安靜到找不到。我要的是一條更粗的左邊框。智能體回過頭來建議改用一條連續的軌線加上一段較粗的作用中區段,理由是一個只有在作用中才存在的邊框,會把其他每一個項目都推移它自己的寬度。

有意思的是軌線該放在哪裡。這份清單的縮排是用 padding-inline-start 編碼的,而巢狀清單本身不帶任何 padding,所以不論深度為何,每個 anchor 的 border-box 起始邊都落在同一個 x 上。 一個釘在那條邊上的標記,會自動被父項與子項共用。而把 border-inline-start 加在 anchor 上,也就是第一個會想到的做法,反而讓軌線每一層階梯式錯開一次。

用 padding 做縮排,任何標題層級都共用同一條軌線兩欄並列,顯示同一份目錄清單:兩個 h2 項目,每個底下各有兩個縮排的 h3 項目。左側的縮排來自 padding,因此六個項目左邊只有一條連續的垂直軌線位於同一個 x 座標,其中一個縮排的 h3 項目以較粗的強調色區段標示。右側在每個連結上加了邊框,h2 項目的軌線在一個 x 座標,縮排的 h3 項目則在更深的 x 座標,於是軌線不再是一條線,而是階梯狀的斷開區段。清單旁只有一條軌線padding 推的是文字,不是方框目前不論層級,只有一條軌線每個連結各自的邊框邊框跟著縮排後的方框移動目前每個層級各一條軌線
縮排住在 padding 裡,所以一條軌線就能同時服務兩個標題層級。把邊框放在 anchor 上,則會變成一個層級一條軌線。

這個標記永遠都會被畫出來,變的只有顏色,所以在章節之間移動不會推移任何文字:

src/components/TocList.astro
a::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: -2px;
inline-size: 3px;
background: transparent;
}
a[aria-current='true']::before {
background: var(--toc-marker, transparent);
}

它是往內加粗,蓋在 anchor 自己的 padding 上,而不是往外。往外長會突出捲動容器,而 overflow-y: auto 會把 overflow-x 也算成 auto,於是這一點突出,就會為了一個像素的軌線換來一條水平捲軸。

側邊欄的目錄,清單旁邊有一條連續的垂直軌線,其中一段較粗的藍色區段標示出作用中的項目,清單在底部邊緣淡出

成果:一條軌線、目前章節上一段加粗的強調區段,以及清單在兩端延續下去的淡出效果。

第一個顏色陷阱底下還藏著第二個。在強制色彩模式下,標記的 background 會被覆寫成 Canvas,每個連結的 color 都塌成 LinkText,於是那個為了取代顏色訊號而做出來的非顏色訊號,剛好對它所服務的那群讀者消失了。 修法就是這個儲存庫裡閱讀進度條已經在用的模式:

src/components/TocList.astro
@media (forced-colors: active) {
a[aria-current='true']::before {
background: Highlight;
forced-color-adjust: none;
}
}

固定標題花掉了整欄的 10.3%

這是我判斷錯的地方,而上面那個標題裡的數字就是它的代價。

我要求把「On this page」這個標題改成固定的,讓它在清單捲動時仍然可見。智能體不建議這麼做,而我推翻了它;在一個未經量測的主張面前,這是個合理的決定。然後它被做出來,也被量了。

固定標題會吃掉側邊欄整整一成的高度兩根以相同比例繪製的直條,各自代表同一欄 671px 的側邊欄。左邊固定了標題的那一根,把最上方的 68.8px、也就是整欄的 10.3% 交給「On this page」固定帶,因此作用中的項目可停留的區間只剩 546px。右邊沒有標題帶,同一個區間有 599px。兩個區間的下緣位置相同,所以 53px 的差距全部集中在右邊直條頂端,以標示出來的一段呈現。固定標題時不固定時固定的「On this page」標題68.8px佔整欄的 10.3%作用中的項目可停留的區間546px671px 的欄+53px區間多出來的空間作用中的項目可停留的區間599px671px 的欄固定的標題會永久佔用整欄的 10.3%,而且是從「跟隨讀者」區間所用的同一份額度裡扣掉的。
在實際頁面上量到的。被釘住的標題吃掉整欄的十分之一,而它的每一個像素,都是從揭示機制可用的區間裡扣出來的。

在一欄 671px 的高度上,被釘住的標題是 68.8px,也就是整欄的 10.3%,而且是永久的。 更糟的是,它不只是佔掉空間,它佔的還是上一節所花費的同一份額度:作用中的項目被允許停留的區間,從 599px 縮到 546px,於是揭示觸發得更早、也更頻繁。我要求的那個功能,和我剛剛才批准的那個功能,正在互相拉扯。 這個代價的縮放方向也是錯的,因為一個比較矮的筆電可視區域會讓欄位變短,而 68.8px 還是 68.8px。

我把它拿掉了。那個標籤自明地就是一份文章標題清單的說明文字,不會有任何讀者搞不清楚那一欄是什麼。

那個不透明的標題原本默默提供的一件事,確實得換個做法補上。閱讀進度條位於網站頁首內 bottom: -1px 的位置,高度是 3px,所以它突出到側邊欄最上面的兩個像素上,一直在把捲過去的項目頂端切掉。用一段遮罩漸層來處理這件事,完全不用花掉任何高度:

src/layouts/BaseLayout.astro
mask-image: linear-gradient(
to bottom,
transparent,
#000 1rem,
#000 calc(100% - 2rem),
transparent
);

那段漸層接著製造出它自己的缺陷,是在校閱時抓到的。循序焦點導覽只會把取得焦點的元素捲到剛好進入視野,這會讓它停在漸層的斜坡裡,把它的焦點外框算繪成半透明。在同一個元素上加 scroll-padding-block: 1rem 2rem,可以讓瀏覽器自己的焦點捲動落在兩段淡出之外。

::details-content 吃掉了手機版的 max-height

在 72rem 以下,目錄是一張黏在頁首底下的 details 卡片。它完全沒有高度上限,所以在手機上打開它,會長出一份跑到螢幕底部外面、而且沒辦法回頭的清單。我要求給它加上上限。

第一次嘗試是最整齊的那個。把 details 做成一個 flex 直向容器並給它 max-block-size,讓裡面的 nav 拿走 summary 剩下的空間:

details {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - var(--nav-height) - 2rem);
}
details nav {
min-block-size: 0;
overflow-y: auto;
}

在瀏覽器裡量測,卡片正確地被限制在 628px。裡面的 nav 回報的高度卻是 1057px,而 scrollHeight - clientHeight 等於 0。 它是在溢出,不是在捲動,而那段程式碼裡的每一條宣告,都完全照著字面在生效。

成因是一個不會出現在標記裡的框。

details 裡真正的 flex 項目是 ::details-content一張框樹圖。最外層是設定了 display: flex 與 flex-direction: column 的 details 元素,它的兩個 flex 項目以直接子層繪出:一個 summary 框,以及一個以虛線繪製的 ::details-content 框,後者標示為瀏覽器產生、不在標記裡,計算值為 display: block。nav 元素巢狀在那個產生的框裡面,多了一層,因此不是 flex 項目。兩則註解說明:寫在 nav 上的 min-block-size: 0 是合法的 CSS,卻套用在錯誤的框上,因此產生的框永遠不會縮小,nav 也就沒有可供捲動的受限父層;而虛擬元素無法從 script 取得,所以捲動容器必須是真實的 nav 元素。detailsdisplay: flex; flex-direction: column兩個 flex 項目summaryflex 項目::details-content瀏覽器產生的框,不在你的標記裡display: blockflex 項目nav不是 flex 項目 — 多了一層正確的 CSS,錯誤的框min-block-size: 0 寫在 nav 上,而且是確實會生效的合法 CSS。但 nav 並不是 flex 項目,所以產生的框永遠不會縮小,始終保持內容的完整高度。為什麼上限要放在 nav 上虛擬元素無法從 script 取得,因此捲動容器必須是真實存在的元素。針對 nav 撰寫的 CSS 會乾淨地生效,然後什麼也沒發生。nav 沒有高度受限的父層,因此只會溢出而不會捲動。
被 slot 進來的內容,會被包進一個產生出來的框裡。nav 是孫節點,所以瞄準它的 flex 屬性會生效,但什麼都不會發生。

瀏覽器會把 details 元素被 slot 進來的內容,包進一個 ::details-content 虛擬元素裡,而真正的 flex 項目是那個框,不是 nav 所以 nav 上的 min-block-size: 0 是套用在錯誤的框上的正確 CSS:那個產生出來的框從來沒有縮小過,於是 nav 沒有一個有界的父容器可以在裡面捲動。Chrome 151 回報它是 display: block,計算後的高度是 0px

把上限移到 ::details-content 上會有效,但依然是錯的,因為虛擬元素無法從指令碼觸及,而這張卡片打開時需要被捲動到作用中的項目。因此上限落在 nav 自己身上,這讓它同時是捲動容器,也是 querySelector 拿得回來的東西:

src/components/TableOfContents.astro
details nav {
max-block-size: calc(100dvh - var(--nav-height) - var(--summary-h) - 2rem);
overflow-y: auto;
overscroll-behavior: contain;
}

這一條宣告裡有三個細節:

  • dvh 而不是 vh 手機瀏覽器的介面會在你捲動時收起來,而 vh 量的是它展開時的狀態,所以卡片會剛好突出工具列的高度。
  • --summary-h 宣告在 details 上,再餵回給 summary 當作它的 min-block-size,所以上限所扣掉的那個數字,和那條 summary 的實際高度不會各走各的。這條 summary 在構造上就是單行的,因為它的兩個 span 都是 nowrap,長的那個會以刪節號截斷。
  • overscroll-behavior: contain 阻止清單捲到底之後的一次滑動連鎖到下方的文章上。

給卡片加上限,等於重現了桌機欄位原本的問題,所以這張卡片是在 toggle 時追上作用中的項目,而不是在捲動時。卡片開著,代表讀者正在挑一個目的地,而不是順著讀下去;在它開著的時候跟隨他們,只會是噪音。

數字說了什麼

驗證捲動行為本身也有兩個陷阱,而且每一個在被抓到之前,都給出過一個很有自信的錯誤答案。

Playwright 的元素截圖會捲動它所量測的那個東西。 呼叫 locator('aside.toc').screenshot() 會先跑一次 scrollIntoViewIfNeeded(),而那捲動了側邊欄自己的捲動容器。拍出來的圖,顯示一欄看似漂亮地跟隨了讀者,而它的 scrollTop 其實還是 0。

全域的平滑捲動意味著你量測的時候頁面還沒到位。 一輪要求 4000、9000 與 14000 的掃描,實際讀到的是 3677、8577 與 13568,而從中得出的每一個結論都是錯的。改用 window.scrollTo({ behavior: 'instant' }) 驅動,並等 1.8 秒讓它安定下來,就修好了。

修正之後,安定下來的位置長這樣。兩個間距欄位是作用中的項目到區間各一邊的距離,所以其中任何一個出現負數,都代表揭示失敗了:

頁面位移 作用中的項目 側邊欄 scrollTop 上緣間距 下緣間距
0 Introduction 0 45 692
4000 Cacheable 0 243 329
9000 Routing the QUERY method 0 450 122
14000 Python client (httpx) 246 572 0
19000 The real axis of competition 605 572 0

前三列是這個功能拒絕動作。 項目位於區間內,所以側邊欄不會移動,而這正是讓它不顯得神經質的行為。在不換章節的情況下用手推一下側邊欄,它會回到原本被放的位置,而且在這整段過程中,window.scrollY 一個像素都沒有變過。

在 390x760 的可視區域上,卡片量到 630px、螢幕是 760px,裡面的清單可捲動 473px,而把它滑到底之後,文章移動了 0。

總結

那個負責告訴你人在哪裡的元件,已經不再告訴我我在哪裡了,而關於這次修復,幾乎沒有一件事出現在我以為會找到它的地方。

  • 這份清單從來沒有溢出過。 它一直都是可捲動的,只是沒有任何東西去捲動它,所以要補的是一個缺席的行為,而不是一個缺席的高度。
  • 最近邊緣勝過重新置中。 只在作用中的項目離開區間時才移動,才能讓欄位在一個章節被閱讀的期間保持不動,也才不會去覆蓋一位手動捲過它的讀者。
  • scroll-behavior 不會繼承。 html 上一個全域的 smooth,對其他任何捲動容器什麼都沒說,而一句主張相反的註解,活得比那個 bug 還久。
  • 一個固定標題花掉了整欄的 10.3%,而且扣的是跟隨讀者的區間所用的同一份額度。是我要求的,量測結果回來了,然後我把它拿掉了。
  • ::details-content 才是 details 裡真正的 flex 項目。 瞄準你所寫下的那個元素的 CSS,會乾淨地生效,而且什麼都不做;又因為虛擬元素從指令碼上無法觸及,捲動容器終究得是一個真的元素。

在這裡真正做了事的是量測。 這裡面有三件事,在有東西被放上秤之前都是看不見的,而其中一件,還是我自己爭取來的功能。

參考連結

分享這篇文章