ขอแนะนํา command และ commandfor

เผยแพร่: 7 มีนาคม 2025

ปุ่มเป็นองค์ประกอบสําคัญในการสร้างเว็บแอปพลิเคชันแบบไดนามิก ปุ่มเปิดเมนู เปิดตัวดำเนินการ และส่งแบบฟอร์ม ภาษาเหล่านี้เป็นรากฐานของการโต้ตอบบนเว็บ การทำปุ่มให้เรียบง่ายและเข้าถึงได้อาจทำให้เกิดปัญหาบางอย่างที่คาดไม่ถึง นักพัฒนาซอฟต์แวร์ที่สร้างไมโครเฟรนเทจหรือระบบคอมโพเนนต์อาจพบโซลูชันที่ซับซ้อนเกินความจำเป็น แม้ว่าเฟรมเวิร์กจะมีประโยชน์ แต่เว็บก็ทําได้มากกว่า

Chrome 135 เปิดตัวความสามารถใหม่ในการระบุลักษณะการทำงานแบบประกาศด้วยแอตทริบิวต์ command และ commandfor ใหม่ ซึ่งจะช่วยปรับปรุงและแทนที่แอตทริบิวต์ popovertargetaction และ popovertarget คุณเพิ่มแอตทริบิวต์ใหม่เหล่านี้ลงในปุ่มได้ ซึ่งจะช่วยให้เบราว์เซอร์จัดการปัญหาหลักๆ บางประการเกี่ยวกับความเรียบง่ายและการช่วยเหลือพิเศษ รวมถึงให้ฟังก์ชันการทำงานทั่วไปในตัว

ลวดลายดั้งเดิม

การสร้างลักษณะการทํางานของปุ่มโดยไม่ใช้เฟรมเวิร์กอาจก่อให้เกิดปัญหาที่น่าสนใจเมื่อโค้ดเวอร์ชันที่ใช้งานจริงพัฒนาขึ้น แม้ว่า HTML จะมีตัวแฮนเดิล onclick สำหรับปุ่ม แต่ระบบมักไม่อนุญาตให้ใช้ตัวแฮนเดิลเหล่านี้นอกการสาธิตหรือบทแนะนำเนื่องจากกฎนโยบายความปลอดภัยของเนื้อหา (CSP) แม้ว่าเหตุการณ์เหล่านี้จะส่งในองค์ประกอบปุ่ม แต่โดยทั่วไปแล้วปุ่มจะวางไว้ในหน้าเว็บเพื่อควบคุมองค์ประกอบอื่นๆ ซึ่งต้องใช้โค้ดเพื่อควบคุมองค์ประกอบ 2 รายการพร้อมกัน นอกจากนี้ คุณต้องตรวจสอบว่าผู้ใช้เทคโนโลยีความช่วยเหลือพิเศษเข้าถึงการโต้ตอบนี้ได้ ซึ่งมักจะทําให้โค้ดมีลักษณะดังนี้

<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>

ระบบการออกแบบหรือไลบรารีบางระบบอาจดำเนินการเพิ่มเติมอีกขั้นด้วยการจัดเตรียม Wrapper ไว้รอบๆ องค์ประกอบปุ่มที่รวมการเปลี่ยนแปลงสถานะ ซึ่งจะแยกการเปลี่ยนแปลงสถานะออกจากคอมโพเนนต์ทริกเกอร์ โดยแลกกับความยืดหยุ่นเล็กน้อยเพื่อปรับปรุงการยศาสตร์

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

รูปแบบคำสั่งและ commandfor

เมื่อใช้แอตทริบิวต์ command และ commandfor ตอนนี้ปุ่มจะดำเนินการกับองค์ประกอบอื่นๆ ได้แบบประกาศ ซึ่งจะเพิ่มประสิทธิภาพของเฟรมเวิร์กโดยไม่ลดทอนความยืดหยุ่น ปุ่ม commandfor จะใช้รหัส ซึ่งคล้ายกับแอตทริบิวต์ for ส่วน command จะยอมรับค่าในตัว ซึ่งช่วยให้ใช้แนวทางที่นําไปใช้ได้จริงและใช้งานง่ายยิ่งขึ้น

ตัวอย่าง: ปุ่มเปิดเมนูที่มีคําสั่งและ commandfor

HTML ต่อไปนี้จะสร้างความสัมพันธ์แบบประกาศระหว่างปุ่มกับเมนู ซึ่งช่วยให้เบราว์เซอร์จัดการตรรกะและการช่วยเหลือพิเศษให้คุณ คุณไม่จำเป็นต้องจัดการ aria-expanded หรือเพิ่ม JavaScript ใดๆ เพิ่มเติม

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

การเปรียบเทียบ command และ commandfor กับ popovertargetaction และ popovertarget

หากคุณเคยใช้ popover มาก่อน คุณอาจคุ้นเคยกับแอตทริบิวต์ popovertarget และ popovertargetaction ซึ่งจะทํางานคล้ายกับ commandfor และ command ตามลําดับ ยกเว้นว่าจะใช้กับป๊อปอัปโดยเฉพาะ แอตทริบิวต์ command และ commandfor จะแทนที่แอตทริบิวต์เก่าเหล่านี้อย่างสมบูรณ์ แอตทริบิวต์ใหม่รองรับทุกสิ่งที่แอตทริบิวต์เก่ารองรับ รวมถึงเพิ่มความสามารถใหม่ๆ

คำสั่งในตัว

แอตทริบิวต์ command มีชุดลักษณะการทำงานในตัวซึ่งแมปกับ API ต่างๆ สําหรับองค์ประกอบแบบอินเทอร์แอกทีฟ ดังนี้

  • show-popover: แมปไปยัง el.showPopover()
  • hide-popover: แมปไปยัง el.hidePopover()
  • toggle-popover: แมปไปยัง el.togglePopover()
  • show-modal: แมปไปยัง dialogEl.showModal()
  • close: แมปไปยัง dialogEl.close()

คำสั่งเหล่านี้จะแมปกับคำสั่ง JavaScript ที่เกี่ยวข้อง พร้อมทั้งปรับปรุงการช่วยเหลือพิเศษ (เช่น การให้ความสัมพันธ์ที่เทียบเท่าของ aria-details และ aria-expanded) การจัดการโฟกัส และอื่นๆ

ตัวอย่าง: กล่องโต้ตอบการยืนยันที่มี command และ commandfor

<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>

การคลิกปุ่มลบระเบียนจะเปิดกล่องโต้ตอบเป็นโมดัล ขณะที่การคลิกปุ่มปิด ยกเลิก หรือลบจะปิดกล่องโต้ตอบไปพร้อมกับส่งเหตุการณ์ "close" ในกล่องโต้ตอบซึ่งมีพร็อพเพอร์ตี้ returnValue ที่ตรงกับค่าของปุ่ม วิธีนี้ช่วยลดความจำเป็นในการใช้ JavaScript นอกเหนือจากเหตุการณ์เดียวที่รับฟังในกล่องโต้ตอบเพื่อกำหนดสิ่งที่ต้องทำต่อไป

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" ไปยังองค์ประกอบเป้าหมาย (เช่นเดียวกับคำสั่งในตัว) แต่จะไม่ดำเนินการตามตรรกะเพิ่มเติมเหมือนค่าในตัว การดำเนินการนี้ช่วยให้คุณสร้างคอมโพเนนต์ที่ตอบสนองต่อปุ่มได้หลายวิธีโดยไม่ต้องระบุคอมโพเนนต์ Wrapper, เรียกใช้ DOM สำหรับองค์ประกอบเป้าหมาย หรือแมปการคลิกปุ่มกับการเปลี่ยนแปลงสถานะ ซึ่งช่วยให้คุณระบุ API ภายใน HTML สําหรับคอมโพเนนต์ได้

<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 ใช้รหัส จึงมีข้อจํากัดเกี่ยวกับการข้าม DOM เงา ในกรณีเหล่านี้ คุณสามารถใช้ JavaScript API เพื่อตั้งค่าพร็อพเพอร์ตี้ .commandForElement ซึ่งสามารถตั้งค่าองค์ประกอบใดก็ได้ใน Shadow Root

<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>
  • คําสั่ง "show-picker" สําหรับองค์ประกอบ <input> และ <select> ซึ่งแมปกับ showPicker()
  • คำสั่งการเล่นสำหรับองค์ประกอบ <video> และ <audio>
  • การคัดลอกเนื้อหาข้อความจากองค์ประกอบ

เรายินดีรับฟังความคิดเห็นจากชุมชน หากมีข้อเสนอแนะ โปรดแจ้งปัญหาในเครื่องมือติดตามปัญหา UI แบบเปิด

ดูข้อมูลเพิ่มเติม

ดูข้อมูลเพิ่มเติมเกี่ยวกับ command และ commandfor ในข้อกำหนดและใน MDN