介紹指令和指令 for

發布日期:2025 年 3 月 7 日

按鈕是製作動態網頁應用程式不可或缺的元素。按鈕可開啟選單、切換動作,以及提交表單。這些元素是網路互動功能的基礎。讓按鈕簡單易用可能會帶來一些意想不到的挑戰。開發人員在建構微前端或元件系統時,可能會遇到解決方案變得過於複雜的情況。雖然架構有助於解決問題,但網路可以提供更多協助。

Chrome 135 推出了新功能,可透過新的 commandcommandfor 屬性提供宣告行為,並強化及取代 popovertargetactionpopovertarget 屬性。這些新屬性可新增至按鈕,讓瀏覽器解決簡易性和無障礙設計方面的部分核心問題,並提供內建的常用功能。

傳統模式

在沒有架構的情況下建構按鈕行為,可能會在實際程式碼演進過程中帶來一些有趣的挑戰。雖然 HTML 會為按鈕提供 onclick 處理常式,但由於內容安全性政策 (CSP) 規則,這些常式通常無法在示範或教學課程以外使用。雖然這些事件會在按鈕元素上分派,但按鈕通常會放在網頁上,用於控制需要程式碼一次控制兩個元素的其他元素。您也必須確保輔助技術使用者能存取這項互動。這通常會導致程式碼看起來像這樣:

<div class="menu-wrapper">
  <button class="menu-opener" aria-expanded="false">
    Open Menu
  </button>
  <div popover class="menu-content">
    <!-- ... -->
  </div>
</div>
<script type="module">
document.addEventListener('click', e => {  
  const button = e.target;
  if (button.matches('.menu-opener')) {
    const menu = button
      .closest('.menu-wrapper')
      .querySelector('.menu-content');
    if (menu) {
      button.setAttribute('aria-expanded', 'true');
      menu.showPopover();
      menu.addEventListener('toggle', e => {
        // reset back to aria-expanded=false on close
        if (e.newState == 'closed') {
          button.setAttribute('aria-expanded', 'false');
        }
      }, {once: true})
    }
  }
});
</script>

這種做法可能會比較不穩定,而架構旨在改善人體工學。使用 React 等架構時,常見的模式可能會將點擊事件對應至狀態變更:

function MyMenu({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const open = useCallback(() => setIsOpen(true), []);
  const handleToggle = useCallback((e) => {
      // popovers have light dismiss which influences our state
     setIsOpen(e.newState === 'open')
  }, []);
  const popoverRef = useRef(null);
  useEffect(() => {
    if (popoverRef.current) {
      if (isOpen) {
        popoverRef.current.showPopover();
      } else {
        popoverRef.current.hidePopover();
      }
    }
  }, [popoverRef, isOpen]);
  return (
    <>
      <button onClick={open} aria-expanded={isOpen}>
        Open Menu
      </button>
      <div popover onToggle={handleToggle} ref={popoverRef}>
        {children}
      </div>
    </>
  );
}

許多其他架構也旨在提供類似的人體工學設計,例如,這段程式碼可能會以 AlpineJS 編寫成以下形式:

<div x-data="{open: false}">
  <button @click="open = !open; $refs.popover.showPopover()" :aria-expanded="open">
    Open Menu
  </button>
  <div popover x-ref="popover" @toggle="open = $event.newState === 'open'">
    <!-- ... -->
  </div>
</div>

在 Svelte 中編寫這段程式碼時,可能會像這樣:

<script>
  let popover;
  let open = false;
  function togglePopover() {
    open ? popover.hidePopover() : popover.showPopover();
    open = !open;
  }
</script>
<button on:click={togglePopover} aria-expanded={open}>
  Open Menu
</button>
<div bind:this={popover} popover>
  <!-- ... -->
</div>

某些設計系統或程式庫可能會更進一步,在按鈕元素周圍提供包裝函式,以便封裝狀態變更。這會將狀態變更抽象化,並在觸發事件元件後方進行,以便在犧牲一點彈性,換取更佳的人因設計:

import {MenuTrigger, MenuContent} from 'my-design-system';
function MyMenu({children}) {
  return (
    <MenuTrigger>
      <button>Open Menu</button>
    </MenuTrigger>
    <MenuContent>{children}</MenuContent>
  );
}

指令和 commandfor 模式

有了 commandcommandfor 屬性,按鈕現在可以以宣告方式對其他元素執行動作,在不犧牲彈性的情況下,提供架構的人體工學。commandfor 按鈕會採用 ID (類似 for 屬性),而 command 則會接受內建值,提供更具可攜性和直覺性的做法。

範例:含有指令和 commandfor 的開啟選單按鈕

以下 HTML 會在按鈕和選單之間建立宣告式關係,讓瀏覽器處理邏輯和無障礙功能。您不需要管理 aria-expanded 或新增任何額外的 JavaScript。

<button commandfor="my-menu" command="show-popover">
Open Menu
</button>
<div popover id="my-menu">
  <!-- ... -->
</div>

比較 commandcommandforpopovertargetactionpopovertarget

如果您曾使用 popover,可能就熟悉 popovertargetpopovertargetaction 屬性。這些方法的運作方式與 commandforcommand 類似,只是分別適用於彈出式視窗。commandcommandfor 屬性完全取代這些舊屬性。新屬性支援舊屬性支援的所有功能,並新增其他功能。

內建指令

command 屬性具有一組內建行為,可對應至互動式元素的各種 API:

  • show-popover:對應至 el.showPopover()
  • hide-popover:對應至 el.hidePopover()
  • toggle-popover:對應至 el.togglePopover()
  • show-modal:對應至 dialogEl.showModal()
  • close:對應至 dialogEl.close()

這些指令會對應至對應的 JavaScript 指令,同時簡化無障礙功能 (例如提供 aria-detailsaria-expanded 等同關係)、焦點管理等。

範例:含有 commandcommandfor 的確認對話方塊

<button commandfor="confirm-dialog" command="show-modal">
  Delete Record
</button>
<dialog id="confirm-dialog">
  <header>
    <h1>Delete Record?</h1>
    <button commandfor="confirm-dialog" command="close" aria-label="Close" value="close">
      <img role="none" src="/close-icon.svg">
    </button>
  </header>
  <p>Are you sure? This action cannot be undone</p>
  <footer>
    <button commandfor="confirm-dialog" command="close" value="cancel">
      Cancel
    </button>
    <button commandfor="confirm-dialog" command="close" value="delete">
      Delete
    </button>
  </footer>
</dialog>

按一下「Delete Record」按鈕,系統會以模態方式開啟對話方塊,而按一下「Close」、「Cancel」或「Delete」按鈕則會關閉對話方塊,並在對話方塊上調度 "close" 事件,該事件具有與按鈕值相符的 returnValue 屬性。這樣一來,您就不需要在對話方塊中使用單一事件事件監聽器,以便判斷下一步要採取哪些行動:

dialog.addEventListener("close", (event) => {
  if (event.target.returnValue == "cancel") {
    console.log("cancel was clicked");
  } else if (event.target.returnValue == "close") {
    console.log("close was clicked");
  } else if (event.target.returnValue == "delete") {
    console.log("delete was clicked");
  }
});

自訂指令

除了內建指令外,您也可以使用 -- 前置字元定義自訂指令。自訂指令會在目標元素上調度 "command" 事件 (就像內建指令一樣),但不會執行任何內建值的額外邏輯。這可讓您彈性地建構可以以各種方式回應按鈕的元件,而無需提供包裝函式元件、遍歷目標元素的 DOM,或將按鈕點擊事件對應至狀態變更。這樣一來,您就可以為元件提供 HTML 內部的 API:

<button commandfor="the-image" command="--rotate-landscape">
 Landscape
</button>
<button commandfor="the-image" command="--rotate-portrait">
 Portrait
</button>

<img id="the-image" src="photo.jpg">

<script type="module">
  const image = document.getElementById("the-image");
  image.addEventListener("command", (event) => {
   if ( event.command == "--rotate-landscape" ) {
    image.style.rotate = "-90deg"
   } else if ( event.command == "--rotate-portrait" ) {
    image.style.rotate = "0deg"
   }
  });
</script>

ShadowDOM 中的指令

由於 commandfor 屬性會使用 ID,因此跨越影子 DOM 時會受到限制。在這種情況下,您可以使用 JavaScript API 設定 .commandForElement 屬性,該屬性可在陰影根目錄中設定任何元素:

<my-element>
  <template shadowrootmode=open>
    <button command="show-popover">Show popover</button>
    <slot></slot>
  </template>
  <div popover><!-- ... --></div>
</my-element>
<script>
customElements.define("my-element", class extends HTMLElement {
  connectedCallback() {
    const popover = this.querySelector('[popover]');
    // The commandForElement can set cross-shadow root elements.
    this.shadowRoot.querySelector('button').commandForElement = popover;
  }
});
</script>

日後的提案可能會提供宣告式方法,讓您在陰影邊界之間共用參照,例如參照目標提案

後續步驟

我們會持續探索新的內建指令的可能性,以涵蓋網站使用的常見功能。開放式 UI 提案中會說明建議的想法。我們已探索過的部分想法如下:

  • 開啟和關閉 <details> 元素。
  • <input><select> 元素的 "show-picker" 指令,對應至 showPicker()
  • <video><audio> 元素的播放指令。
  • 複製元素中的文字內容。

歡迎社群提供意見。如有建議,歡迎在 Open UI Issue Tracker 中提出問題。

瞭解詳情

如要進一步瞭解 commandcommandfor,請參閱規格MDN