skip to content
BlogZzz

[Web] 面板無法滑入:focus、scroll 與 transform 的衝突

/ 9 min read

Updated:
Table of Contents

元件功能

FavoriteListPicker 是使用者加入收藏時顯示的選擇面板,負責列出既有的 Favorite Lists,並讓使用者把目前內容加入指定清單

如果沒有合適的清單,使用者也可以直接點擊「Create Favorite List」,切換到建立清單面板並輸入新清單名稱

完整互動包含以下幾個步驟:

  • 顯示使用者目前的 Favorite Lists
  • 選擇要加入的收藏清單
  • 從收藏列表切換到建立清單面板
  • 自動把游標放進名稱輸入框

下面是問題修正後的完整互動,先看過元件實際操作方式,會比較容易理解後面的面板滑動問題

遇到的問題

這個收藏元件在切換建立清單面板時,遇到一個很容易誤判的問題

使用者點擊「Create Favorite List」後,預期收藏列表向左滑出,建立清單面板從右側滑入,並且自動把游標放進名稱輸入框

但實際畫面上,建立面板完全看不見

一開始看起來很像 CSS transition 沒有生效,後來才發現真正影響畫面位置的是 input 的 focus()

問題情境

FavoriteListPicker 會在同一個水平 overflow 容器中放置兩個狀態:收藏列表與建立收藏清單面板

簡化後的結構大概如下:

<FavoriteListPicker>
<FavoriteListsPanel /> // 收藏列表
<CreateFavoriteListPanel /> // 建立收藏清單面板
</FavoriteListPicker>

當使用者點擊建立按鈕時,元件會切換 state,再透過 transform class 移動內容

  • 收藏列表向左移動
  • 建立清單面板從右側進入可視範圍
  • 建立面板開啟後,名稱輸入框自動取得 focus

這個互動看起來是很標準的 Drawer 或滑動面板流程,問題也不是每次都能直接從程式碼看出來

實際發生的現象

當時檢查了 React state 和套用的 class,結果都符合預期

  • 建立面板的 state 已經是開啟狀態
  • 向左移動與滑入的 transform class 都已經套用
  • transition 和 duration 也沒有被覆寫
  • input 確實存在於 DOM 中

但是畫面上的建立面板仍然看不見

這代表問題不一定是「動畫沒有執行」,也可能是元素已經被移動到視窗之外

原因分析:focus 不只是設定焦點

一般使用 focus() 時,直覺上會把它當成單純的輸入操作:讓使用者可以直接打字

但瀏覽器在 focus 元素時,通常還會嘗試把這個元素捲動到可視範圍內

假設建立面板原本位於水平 overflow 容器的右側,開啟面板後立刻執行:

nameInputRef.current?.focus();

瀏覽器可能會自動調整父層的 scrollLeft,把 input 捲到畫面中

同一個時間,元件又正在套用向左的 transform,兩個位置變化就會疊在一起:

  1. React state 切換,建立面板開始套用新的 transform
  2. focus() 嘗試讓 input 出現在可視範圍
  3. 水平 overflow 容器的 scrollLeft 被瀏覽器改變
  4. 面板同時受到 scroll 與 transform 影響,最後落在預期位置之外

這裡所謂的「移動兩次」,不代表 CSS transform 真的執行了兩次,而是元素同時受到兩個座標系統的影響:父層的捲動位置與自己的 transform

因此最後看到的結果就像是:state 正確、class 正確,但面板沒有滑入

解法:保留 focus,但禁止 focus 自動捲動

focus() 支援傳入 FocusOptions,其中 preventScroll: true 可以禁止這次 focus 行為改變祖先捲動容器的位置

const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isCreatingList) return;
nameInputRef.current?.focus({
preventScroll: true,
});
}, [isCreatingList]);

input 本身只需要正常掛上 ref:

<input
ref={nameInputRef}
name="favorite-list-name"
placeholder="List name"
type="text"
/>

修正後會得到兩個效果:

  • input 仍然會取得 focus,使用者可以直接輸入名稱
  • focus 不會改變水平 overflow 容器的 scroll position

如此一來,滑動面板的 transform 就能在原本預期的座標中執行,建立面板也能正常從右側滑入

如何確認是不是 scroll 造成的問題

遇到「state 和 CSS 都正確,但畫面位置不對」時,可以先暫時移除 focus(),觀察面板是否恢復正常

也可以直接比較 focus 前後的 scrollLeft

const container = scrollContainerRef.current;
const scrollLeftBefore = container?.scrollLeft;
nameInputRef.current?.focus();
const scrollLeftAfter = container?.scrollLeft;
console.log({ scrollLeftBefore, scrollLeftAfter });

如果兩個數值在沒有其他捲動程式碼的情況下發生變化,就表示這次 focus 觸發了瀏覽器的自動捲動

檢查這類問題時,可以依照下面的順序排查:

  • 先確認 state 是否真的切換
  • 再確認 transform class 是否套用到正確的元素
  • 暫時移除 focus,確認面板是否能正常滑動
  • 檢查 overflow 父層的 scrollLeft 是否在開啟時被改變
  • 最後再檢查 transition、position、width 與 stacking context

這個順序可以把「狀態問題」、「動畫問題」和「瀏覽器自動捲動」分開,不必一開始就反覆調整 CSS 數值

使用 preventScroll 時的注意事項

preventScroll 只會阻止這次 focus 造成的自動捲動,不會阻止其他程式碼呼叫 scrollIntoView() 或直接修改 scrollLeft

另外,禁止自動捲動不代表可以忽略可及性

  • 面板開啟時仍應該把 focus 放到主要輸入欄位
  • 面板關閉時要確認不可見內容不會繼續出現在鍵盤 tab 順序中
  • 如果有 overlay 或 Drawer 語意,也要同步處理適當的 aria 屬性

對這個收藏清單建立面板來說,輸入框會隨著面板動畫進入預期位置,因此保留 focus、禁止 focus 改變 scroll position 是比較合適的行為

結論

這次問題的根本原因不是 React state,也不是 CSS class 沒有切換,而是 focus() 在 overflow 容器中帶來了額外的 scroll 行為

當動畫元件同時使用 overflow、scroll 與 transform 時,focus 也必須被視為會影響版面的操作

如果遇到面板「看起來沒有滑入」的問題,可以先檢查以下幾件事:

  • focus 前後容器的 scrollLeft 是否改變
  • 是否在 transform 動畫開始時立即呼叫 focus()
  • 這次 focus 是否應該使用 preventScroll: true

很多看似 CSS 的定位問題,最後其實是瀏覽器為了協助使用者看見 focused element 而主動調整了捲動位置