[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,兩個位置變化就會疊在一起:
- React state 切換,建立面板開始套用新的 transform
focus()嘗試讓 input 出現在可視範圍- 水平 overflow 容器的
scrollLeft被瀏覽器改變 - 面板同時受到 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 而主動調整了捲動位置