חדש: הפקודה command והפקודה commandfor

תאריך פרסום: 7 במרץ 2025

לחצנים הם חיוניים ליצירת אפליקציות אינטרנט דינמיות. לחצנים פותחים תפריטים, מפעילים פעולות ומגישים טפסים. הם מספקים את הבסיס לאינטראקטיביות באינטרנט. עיצוב לחצנים פשוט ונגיש יכול להוביל לאתגרים מפתיעים. מפתחים שמפתחים חזיתות משנה או מערכות רכיבים עלולים למצוא פתרונות שמתבררים כמסובכים יותר מהצורך. אמנם מסגרות יכולות לעזור, אבל האינטרנט יכול לעשות יותר.

ב-Chrome 135 נוספו יכולות חדשות לספק התנהגות מצהירה באמצעות המאפיינים החדשים command ו-commandfor, שמשפרים ומחליפים את המאפיינים popovertargetaction ו-popovertarget. אפשר להוסיף את המאפיינים החדשים האלה ללחצנים, וכך לאפשר לדפדפן לטפל בבעיות מרכזיות מסוימות שקשורות לפשטות ולנגישות, ולספק פונקציונליות משותפת מובנית.

תבניות מסורתיות

בניית התנהגויות של לחצנים ללא מסגרת יכולה להציג כמה אתגרים מעניינים ככל שהקוד בסביבת הייצור מתפתח. ב-HTML יש טיפולים (handlers) מסוג 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>
    </>
  );
}

הרבה frameworks אחרים גם נועדו לספק ארגונומיה דומה. לדוגמה, אפשר לכתוב את הקוד הזה ב-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>
  );
}

התבנית command ו-commandfor

בעזרת המאפיינים command ו-commandfor, לחצנים יכולים עכשיו לבצע פעולות על רכיבים אחרים באופן דקלרטיבי, וכך ליהנות מהארגונומיה של מסגרת בלי להתפשר על הגמישות. הלחצן commandfor מקבל מזהה – בדומה למאפיין for – בעוד שהלחצן command מקבל ערכים מובנים, ומאפשר גישה ניידת ואינטואיטיבית יותר.

דוגמה: כפתור פתיחת תפריט עם 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>

לחיצה על הלחצן Delete Record תפתח את תיבת הדו-שיח בתור תיבת דו-שיח רגילה, ואילו לחיצה על הלחצנים Close,‏ Cancel או Delete תסגור את תיבת הדו-שיח ותשלח גם אירוע "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" לרכיב היעד (בדיוק כמו הפקודות המובנות), אבל לעולם לא יבצעו לוגיקה נוספת כמו הערכים המובנים. כך אפשר ליצור רכיבים גמישים שיכולים להגיב ללחצנים בדרכים שונות, בלי לספק רכיבי עטיפה, לעבור על 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 מקבל מזהה, יש הגבלות על מעבר ל-Shadow 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>

הצעות עתידיות עשויות לספק דרך מוצהרנית לשתף הפניות מעבר לגבולות של צללים, כמו הצעה לטירגוט של הפניות.

מה השלב הבא?

אנחנו נמשיך לבדוק אפשרויות לפקודות מובנות חדשות, כדי לכסות פונקציונליות נפוצה שבה אתרים משתמשים. הרעיונות המוצעים מפורטים בהצעה לממשק משתמש פתוח. חלק מהרעיונות שכבר נבדקו:

  • פתיחה וסגירה של רכיבי <details>.
  • פקודת "show-picker" לרכיבים <input> ו-<select>, שממופה ל-showPicker().
  • פקודות הפעלה לרכיבים <video> ו-<audio>.
  • העתקת תוכן טקסט מרכיבים.

אנחנו מקבלים בברכה משוב מהקהילה. אם יש לכם הצעות, אתם מוזמנים לדווח על בעיה בכלי למעקב אחר בעיות בממשק המשתמש הפתוח.

מידע נוסף

מידע נוסף על command ו-commandfor זמין במפרט וב-MDN.