話說在前頭:文中的行為我在 Chromium 153 上實測過,141 還不支援。依照 Chrome 152 的版本資訊,這個功能是在 152 正式推出的;Firefox 和 Safari 的狀況我寫這篇時沒辦法確認,上線前請以 caniuse 為準。有講錯的地方歡迎指正。
前言
用 Shadow DOM 做表單元件的人,應該都遇過這個問題:
<label for="email">電子郵件</label>
<my-input id="email"></my-input>
<my-input> 的 Shadow DOM 裡有一個真正的 <input>。看起來 label 有指向元件,但報讀軟體聽到的 input 沒有名稱,點 label 焦點也不會進到 input 裡。
原因是 id 不會穿過 Shadow DOM 的邊界。for="email" 找到的是外面的 <my-input>,它不是表單元件,label 不知道要把名稱交給誰;而 Shadow DOM 裡的 input,外面的 id 也指不到。aria-labelledby、aria-describedby 這些用 id 互相參照的屬性,全都有一樣的問題。
CSS 與 HTML 技巧清單裡的 shadowrootreferencetarget,就是在解決這件事。
先看一下你的瀏覽器支援度:
你的瀏覽器支援度
referenceTarget偵測中(需要 JavaScript)
一、以前的解法
在 reference target 出現之前,常見的做法有三種,各有代價:
| 做法 | 代價 |
|---|---|
| 把 label 也放進元件裡(例如用屬性傳文字進去) | 版面和文字都被元件綁住,外面不能自由排版 |
用 JavaScript 把外面的文字複製成內部 input 的 aria-label | 外面的文字改了要記得同步,而且只有名稱,沒有點 label 會 focus 的行為 |
讓外框元件自己扮演 input(加 role、tabindex) | 鍵盤、輸入、表單行為全部要自己補,很容易漏 |
二、寫法:告訴瀏覽器「指向我,其實是指向裡面的誰」
在 Shadow root 上指定一個內部元素的 id,外面用 id 指向這個元件時,瀏覽器就會改成指向那個元素。
宣告式的寫法,加在 <template> 上:
<label for="email">電子郵件</label>
<my-input id="email">
<template shadowrootmode="open" shadowrootreferencetarget="inp">
<input id="inp" type="email">
</template>
</my-input>
<template shadowrootmode> 的寫法在宣告式 Shadow DOM 那篇有介紹過。
用 JavaScript 建立 Shadow DOM 時,寫在 attachShadow() 裡,之後也可以改:
const root = this.attachShadow({ mode: "open", referenceTarget: "inp" });
root.referenceTarget = "inp";
三、動手試試
下面兩邊都是外面的 <label for> 指向一個 Shadow DOM 元件,差別只在右邊有設定 reference target。點「電子郵件」看看焦點會不會進到 input:
互動範例:兩邊都是外面的 <label for> 指向元件,點「電子郵件」看看焦點會不會進到元件裡的 input
沒有設定 reference target點上方的「電子郵件」試試看
shadowrootreferencetarget="inp"點上方的「電子郵件」試試看
偵測中(需要 JavaScript)
此處為互動範例,請在網頁上操作。
提醒:左邊範例裡的 input 是刻意沒有名稱的,用來示範這個問題。用報讀軟體閱讀這篇時,會聽到一個沒有名稱的編輯欄位,直接略過就好;用 axe 這類工具檢查這一頁,也會回報它。
我在 Chromium 153 的無障礙樹裡看到的結果:
- 左邊:input 沒有名稱,點 label 焦點也不會動。
- 右邊:input 的名稱是「電子郵件」,點 label 會 focus 到 input,就像一般的
<label>和<input>一樣。
四、不只 label
所有「用 id 指向別人」的屬性,指到設定了 reference target 的元件時,都會轉給裡面的元素。我實測過的:
| 外面的屬性 | 結果 |
|---|---|
<label for> | 名稱接上,點 label 會 focus |
aria-labelledby、aria-describedby | 可以只拿元件裡的某一段文字當名稱或說明 |
commandfor、popovertarget | 按鈕可以打開元件裡的 <dialog>、popover |
aria-controls、aria-activedescendant | 可以指到元件裡的元素 |
| 元件裡再包元件 | 外層指向內層元件、內層再指向 input,會一路傳到最裡面 |
例如一個顯示提示訊息的元件,裡面有一個「小提醒」標題和真正的提示文字。外面的 input 用 aria-describedby 指向它,只想讓報讀軟體唸出提示文字:
<input aria-label="手機號碼" aria-describedby="phone-hint">
<form-hint id="phone-hint">
<template shadowrootmode="open" shadowrootreferencetarget="msg">
<strong>小提醒</strong>
<p id="msg">格式:0912-345-678</p>
</template>
</form-hint>
我實測這個 input 的說明是「格式:0912-345-678」。
要注意的是,aria-labelledby、aria-describedby 指向元件外框時,瀏覽器本來就會把整個元件的文字(包含 Shadow DOM 裡的)拿來當名稱或說明。所以拿掉 shadowrootreferencetarget,說明一樣讀得到,只是會變成「小提醒 格式:0912-345-678」,連標題一起唸。reference target 在這裡的作用,是只指定其中一段。
按鈕打開元件裡的 dialog 也一樣,dialog 那篇介紹的 commandfor 可以直接指向元件:
<button commandfor="settings" command="show-modal">設定</button>
<settings-panel id="settings">
<template shadowrootmode="open" shadowrootreferencetarget="dlg">
<dialog id="dlg">…</dialog>
</template>
</settings-panel>
五、做不到的事
實測時也發現幾件它不管的事:
| 情況 | 結果 |
|---|---|
反方向:元件裡的 input 寫 aria-describedby="外面的 id" | 沒有作用。reference target 只處理「外面指進來」 |
寫在元件外框上的 aria-label | 不會轉給裡面,只有用 id 參照的屬性會轉 |
| 指定的 id 在元件裡不存在 | 默默失效,不會有任何錯誤訊息 |
| 一個元件只能指定一個目標 | 想讓 aria-activedescendant 在選項之間移動,就得一直改 referenceTarget,不太實際 |
| 表單送出 | 元件裡的 input 不會跟著外面的 <form> 送出,還是要用 form-associated custom element(ElementInternals) |
label.control | 回傳的是元件外框,不是裡面的 input |
反方向的需求,要改用 ARIA 的元素參照屬性,直接把外面的元素交給裡面的 input。我實測這樣可以接上:
input.ariaDescribedByElements = [document.getElementById("outside-hint")];
六、測試工具會誤報
我用 axe-core 4.10.2 檢查第三節右邊那種寫法,它會回報「表單元素沒有標籤」。但實際的無障礙樹裡,input 的名稱是對的。
所以用了 reference target 的元件,不能只看自動化工具的結果。要用開發者工具的無障礙面板,或直接開報讀軟體確認。新版的測試工具之後可能會支援,到時候再重測。
七、實際例子:我的 UI Kit
我的 Accesserty UI Kit 是用 Shadow DOM 做的 Web Components,剛好可以拿來看看這個功能能幫上什麼。
以 2.0.8 版的 <au-input> 為例,為了讓外面的 label 和說明接得上,它現在做了不少事:
- 用 MutationObserver 監看整頁,外面的
aria-labelledby、aria-describedby有變動就重新同步。 - 支援
ariaLabelledByElements的瀏覽器,把外面的元素直接交給裡面的 input;不支援的,把文字複製成aria-label。說明也一樣:支援ariaDescribedByElements的瀏覽器直接交給 input,不支援的才複製到 Shadow DOM 裡一個隱藏的元素。 - 外面的
<label for>,透過 form-associated custom element 的internals.labels取得。
我在 Chromium 153 實測,名稱、說明、表單送出都正常。但有一個小缺口:點外面的 <label>,焦點不會進到 input 裡。滑鼠使用者習慣點 label 來聚焦欄位,這個行為少了。
下面是真的 <au-input>。勾選後,範例會在這個元件上設定 referenceTarget,模擬加上這個功能的效果(UI Kit 本身沒有改):
互動範例:Accesserty UI Kit 的 <au-input>,點「電子郵件」看看焦點會不會進到 input
點上方的「電子郵件」試試看
偵測中(需要 JavaScript)
此處為互動範例,請在網頁上操作。
加上 reference target 之後:
- 可以解決的:點 label 會 focus 到 input;label 和說明不用再靠 JavaScript 同步,瀏覽器原生就會接上。
- 還是要保留的:
list屬性指向外面的<datalist>,是「從裡面指到外面」,reference target 管不到,還是要複製;表單送出還是要靠 ElementInternals;不支援的瀏覽器還很多,現有的同步程式要留著當備案。
所以短期內不是「刪掉一堆程式」,而是支援的瀏覽器走原生,不支援的走原本的路。
八、不支援的時候怎麼辦?
偵測方式:
const supported = "referenceTarget" in ShadowRoot.prototype;
不支援的瀏覽器,shadowrootreferencetarget 屬性會被忽略,結果就跟沒寫一樣,不會壞掉,只是 label 接不上。所以可以先寫上,再用偵測決定要不要執行原本的同步程式:
if (!("referenceTarget" in ShadowRoot.prototype)) {
// 原本的做法:把外面的文字複製成內部 input 的 aria-label
syncLabelFallback();
}
結論
整理成幾句話:
- id 不會穿過 Shadow DOM 的邊界,所以外面的
<label for>、aria-*指不到元件裡的元素。 shadowrootreferencetarget(或attachShadow的referenceTarget)讓元件指定「指向我,其實是指向裡面的誰」。- 它適用所有用 id 參照的屬性:
for、aria-labelledby、aria-describedby、commandfor、popovertarget等。 - 它只管「外面指進來」;反方向要用
ariaDescribedByElements這類屬性,表單送出還是要靠 ElementInternals。 - 自動化測試工具可能誤報,要用無障礙樹或報讀軟體確認。
這是 Web Components 無障礙最大的一塊缺口,終於有原生的解法了。你的元件裡,有多少程式是在幫 label 和 ARIA 搬文字?
延伸閱讀
- Web Component 為什麼會閃一下?用宣告式 Shadow DOM 解決
- 現在的 dialog 要怎麼寫?從 showModal() 到 command、closedby 一次整理
- 現代 CSS 與 HTML 技巧整理清單:115 個特性、支援度與實驗優先序