יצירת תפריט נפתח

יצירת תפריט נפתח

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

שתי אפשרויות נפרדות. כל שאר המידע כאן רלוונטי לשני המקרים.

  • אלמנט, לכל פריט. פקודה היא <button type="button">, ויעד הוא <a href> שעדיין נפתח בכרטיסייה חדשה, מועתק ככתובת URL ופועל בלי סקריפט. בתפריט נפתח אפשר לשלב אותם באופן חופשי: שני קישורים ו-6 פקודות, או פקודה אחת ו-6 קישורים, לא משנים את מה שמופיע למטה.
  • מודל אינטראקציה, לכל תפריט נפתח. מקשי החיצים, Home/End ו-Esc הופכים אותו לתפריט: מוסיפים את focusgroup, מאפשרים לו להסיק menuitem בכל פריט, בין אם הוא לחצן או קישור, וגורמים לו להיראות כמו אחד (סרגל רציף או טריגר עם אינדיקטור נפתח; ראו "סרגל תפריטים או לחצני תפריט נפרדים" בהמשך). רשימה קצרה של קישורים שהמשתמשים Tab דרכם היא ניווט: אין focusgroup, אין role="menu", role="menuitem" או aria-haspopup="menu"; צריך לשמור על ה-popover, על Esc ועל שחזור המיקוד, ולעיין במדריך לסרגל הניווט.

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

השתמשו בתבנית התפריט רק לווידג'ט מורכב עם עצירת טאב אחת וניווט באמצעות מקשי החיצים. משתמשים ב-<div focusgroup="menu nomemory">, ולא ברכיב ה-HTML‏ <menu>. אל תוסיפו את role="menu" או את role="menuitem" לתגי העיצוב המשותפים: focusgroup מקורי מסיק את התפקידים האלה, ותפקידים מפורשים חוסמים את ההסקה הזו. צריך להוסיף אותם רק בחלופה לדפדפן שלא נתמך, כמו בדוגמה שבהמשך. ‫nomemory פותח מחדש את הרשימה מהפריט הראשון. המיקוד עובר באמצעות Focusgroup, והתסריט מטפל בתצוגה המקדימה, בהפעלה ובשחזור המיקוד.

פתיחת התפריט ומיקום שלו

הופכים כל טריגר למפעיל פקודות באמצעות commandfor ו-command="toggle-popover". הדפדפן פותח וסוגר את ה-popover בלחיצה, ב-Enter ובמקש הרווח, ומשאיר את הטריגר בתוך קבוצת ה-light-dismiss של ה-popover, כך שלחיצה על טריגר פתוח סוגרת אותו. אל תשתמשו ב-popovertarget או ב-popovertargetaction. במקום זאת, התבנית הזו משתמשת ב-commandfor.

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

<!-- Command invoker: the browser toggles the popover on click, Enter, and Space -->
<button id="file-trigger" class="trigger" type="button" aria-haspopup="menu" aria-expanded="false"
  commandfor="file-menu" command="toggle-popover">
  File
</button>
<!-- Immediately after its trigger, so browsers add no implicit aria-details -->
<div id="file-menu" popover="auto">
  <!-- Nested, not on the popover: Chromium otherwise drops focusgroup keyboard behavior -->
  <div focusgroup="menu nomemory" aria-labelledby="file-trigger">
    <button type="button">New file</button>
    <button type="button">Save</button>
    <button id="export-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
      commandfor="export-menu" command="toggle-popover">
      Export <span aria-hidden="true">›</span>
    </button>
    <!-- Submenu: popover="auto" directly after its trigger, inside the parent focusgroup -->
    <div id="export-menu" popover="auto">
      <div focusgroup="menu nomemory" aria-labelledby="export-trigger">
        <button type="button">PDF</button>
        <button type="button">Plain Text</button>
      </div>
    </div>
  </div>
</div>

נותנים שם לכל תפריט, ומוסיפים את הסמל aria-labelledby שמצביע על הטריגר שלו. משאירים את האינדיקטורים הדקורטיביים של תפריטי המשנה ב-CSS, או מוסיפים aria-hidden="true". כדאי לתת לטריגרים עם aria-expanded="true" סגנון פעיל גלוי כדי שהמשתמשים יוכלו לדעת איזה תפריט פתוח או מוצג בתצוגה מקדימה.

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

  • שורת התפריטים. role="menubar" במאגר (אף פעם לא בחלון קופץ), מעוצב כסרגל אחד רציף ללא רווחים בין הטריגרים. ‫Tab עצירה אחת; ArrowLeft/ArrowRight מעבר בין טריגרים; מעבר עם העכבר מעל מתגים פותח תפריטים. משמש לסרגל פקודות של אפליקציה.
  • כפתורי תפריט נפרדים. כל טריגר הוא עצירה נפרדת של Tab ונראה כמו לחצן נפרד. אין role="menubar", אין מקשי חיצים בין הטריגרים, אין תצוגה מקדימה כשמתמקדים, אין מעבר בין תמונות כשמעבירים את העכבר מעליהן. אם הם מנווטים, צריך לעטוף ב-<nav>, אחרת אין תפקיד. משמש לסרגלי כלים ולכותרות של דפים.

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

מיקום התפריט באמצעות CSS Anchor Positioning

השתמשו במיקום עוגן ב-CSS, ולא בקואורדינטות של JavaScript. גם command="toggle-popover" וגם showPopover({ source: trigger }) מגדירים עוגן משתמע, אז אל תוסיפו כללי anchor-name או position-anchor לכל תפריט. ההתנהגות של התפריט לא יכולה להיות תלויה במיקום של העוגן.

/* Top-level menus sit below their trigger. The implicit anchor comes from the invoker or source. */
[popover] {
  position-area: block-end span-inline-end;
  inset: auto;
}

/* A popover nested in another popover is a submenu: beside its parent item, flipping when it would overflow. */
[popover] [popover] {
  position-area: inline-end span-block-end;
  position-try-fallbacks: flip-inline, flip-block, --below;
}

/* Last resort for narrow viewports: directly below the trigger, indented, so the submenu never
   covers its trigger. */
@position-try --below {
  position-area: block-end span-inline-end;
  margin-inline-start: 1rem;
  /* Fit the area: the submenu's minimum width must not stop this fallback from fitting. */
  min-inline-size: 0;
  max-inline-size: calc(100% - 1rem);
}

השאירו את --below בסוף כדי שהיא תחול רק אם אף אחד מהצדדים לא מתאים. הוא נפתח ישירות מתחת לטריגר, כך שבמסך צר הוא מכסה את הפריטים שמתחת לטריגר. זה המחיר שצריך לשלם כדי שהתפריט יישאר ליד הטריגר. כדאי גם לאפשר לתפריט המשנה להתכווץ לאזור (min-inline-size: 0, max-inline-size פחות הכניסה). אם אין אפשרות חלופית שמתאימה, הדפדפן מדלג על האפשרויות החלופיות ומשתמש במיקום הבסיסי. במקרה כזה, תפריט משנה שרחב מדי בכמה פיקסלים מכסה את הטריגר שלו בלי להציג הודעה.

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

הנפשה של פתיחה וסגירה

אופציונלי. פחות מ-150 אלפיות השנייה, opacity ו-translate קטן בלבד, אותו דבר לגבי תפריטי משנה, בתוך @media (prefers-reduced-motion: no-preference). המיקוד עובר אל toggle, כך שהמעבר אף פעם לא מעכב משתמשים שמשתמשים במקלדת.

@media (prefers-reduced-motion: no-preference) {
  /* Closing: this rule is in effect once :popover-open is removed. */
  [popover] {
    opacity: 0;
    translate: 0 -.25rem;
    transition: opacity .15s, translate .15s, display .15s allow-discrete, overlay .15s allow-discrete;
  }
  /* Opening: faster. */
  [popover]:popover-open {
    opacity: 1;
    translate: 0 0;
    transition-duration: .1s;
    @starting-style { opacity: 0; translate: 0 -.25rem; }
  }
  /* Switching between open menubar menus is instant, as in native menubars. */
  [role="menubar"]:has(> [popover]:popover-open) > [popover] {
    transition: none;
  }
}

‫@starting-style לא חוזר על [popover]: חלונית קופצת סגורה היא display: none, ומעברים לא פועלים ממצב לא מעובד, אז הוא מספק את הערכים שלפני הפתיחה. בלי זה, הפתיחה תהיה פתאומית ורק הסגירה תהיה עם אנימציה. ‫display ו-overlay עם allow-discrete שומרים על הרינדור של החלון הקופץ בשכבה העליונה עד שהמעבר לסגירה מסתיים.

מקלדת ומיקוד

אל תוסיפו מערכת roving-tabindex שנייה בתוך focusgroup. הסקריפט מכסה רק את מה שהמפעילים וקבוצת המיקוד לא מכסים:

  • עצירת טאב אחת לסרגל התפריטים. Tab נכנס בטריגר הראשון ברמה העליונה. ArrowLeft ו-ArrowRight עוברים בין טריגרים ברמה העליונה וגולשים בכל אחד מהקצוות. ‫Home ו-End בטריגר פותחים את התפריט שלו בפריט הראשון או האחרון.
    • הערה: לאיזה טריגר Tab חוזר. ה-APG מאפשר להשתמש בטריגר הראשון או בטריגר האחרון שנעשה בו שימוש. במדריך מצוין שהאפשרות הראשונה היא הנכונה, אבל בהדגמה, עצירת הטאב מועברת באמצעות ArrowLeft ו-ArrowRight, כך שאחרי הניווט באמצעות המקלדת, Shift+Tab חוזר לטריגר האחרון שאליו הגעתם. השימוש בסמן לא מזיז אותו, כך שאותו רצף יכול להפעיל טריגר אחר. שתי האפשרויות מקובלות, אבל צריך לבחור אחת מהן ולהשתמש בה באופן עקבי.
  • השתמשו בתצוגה מקדימה. התמקדות בטריגר ברמה העליונה מציגה את התפריט שלו ומגדירה את aria-expanded="true". המיקוד נשאר בטריגר, ואף פריט לא ממוקד. טשטוש הטריגר סוגר תפריט של תצוגה מקדימה בלבד.
  • מקש Enter ומקש הרווח בתצוגה מקדימה. הם סוגרים את התצוגה המקדימה ומתמקדים בטריגר. הפעולה המקורית של toggle-popover מטפלת בזה, ולכן הסקריפט לא יכול ליירט את המקשים האלה.
  • ArrowDown בתצוגה מקדימה. התפריט נפתח והמיקוד עובר לפריט הראשון.
  • לוחצים על טריגר פתוח או על טריגר בתצוגה מקדימה. סוגר את האפליקציה באופן מקורי, בלי להיכנס לתפריט.
    • הבדל ידוע בטריגרים של תפריטי משנה. לחיצה על טריגר של תפריט משנה גם פותחת וסוגרת אותו, כך שאפשר לסגור תפריט משנה שכבר נפתח בתצוגה מקדימה באמצעות העברת העכבר, או לפתוח תפריט משנה שעדיין לא הופיע. תפריטים מקוריים רק פותחים אותו ולא סוגרים אותו אף פעם. הפעולה הזו לא מזיקה, ולכן המדריך מאשר אותה.
  • לחיצה עם הסמן על טריגר סגור. פותח אותו באופן מקורי. המיקוד נשאר בטריגר, אף פריט לא ממוקד. רק פתיחות של המקלדת נכנסות לתפריט.
  • הסמן מעל פריט. המיקוד מתבצע בלי טבעת, כך שהריחוף והמיקוד הם פריט אחד.
  • העברת הסמן מעל טריגר אחר ברמה העליונה כשתפריט פתוח. סגירת החלונית והצגת תצוגה מקדימה של החלונית שהסמן נמצא מעליה, תוך התמקדות בטריגר שלה בלי טבעת. שום דבר לא פתוח: העברת העכבר לא עושה כלום.
  • הסמן מעל טריגר של תפריט משנה. הצגת תצוגה מקדימה של תפריט המשנה אחרי השהיה קצרה (המיקוד של הסמן מפעיל את אותה תצוגה מקדימה במיקוד). פריט אחים סוגר אותו.
  • בתוך תפריט. ArrowUp ו-ArrowDown משמשים למעבר בין פריטים מופעלים. Home ו-End עוברים לפריט הראשון והאחרון שמופעל, והסקריפט מטפל בזה.
  • ArrowLeft או ArrowRight בתפריט פתוח ברמה העליונה. סגירת התפריט והצגת התפריט הסמוך בתצוגה מקדימה, תוך התמקדות בטריגר שלו, עם מעבר בין הקצוות. ArrowDown ואז מזין אותו. החצים ימינה ושמאלה אף פעם לא מגיעים לתוך תפריט.
  • ArrowRight בטריגר של תפריט משנה. פתיחת תפריט המשנה והעברת המיקוד לפריט הראשון שלו.
  • החץ ימינה על פריט בתפריט משנה שאין לו תפריט משנה משלו. סגירת ההיררכיה והצגת תצוגה מקדימה של התפריט הבא ברמה העליונה, כשהמיקוד נשאר על הטריגר שלו. ArrowDown ואז מזין אותו.
  • ArrowLeft או Escape בתפריט משנה. סגירה רק של תפריט המשנה הזה והחזרת המיקוד לטריגר שלו. התפריט להורים נשאר פתוח.
  • מקישים על Enter או על מקש הרווח בפקודה. מפעיל אותו וסוגר את כל התפריטים הפתוחים.
  • כרטיסייה. יציאה ממערכת התפריטים. אל תלכדו את המיקוד ואל תעברו על כל פקודה. הסגירה לא יכולה להשאיר את המיקוד בתוך תוכן מוסתר.

האירוע command מופעל לפני ש-toggle-popover פותח את התפריט, ולכן המיקוד לא יכול לעבור לתפריט שם. במקום זאת, צריך להעביר את המיקוד לאירוע של toggle. HTML משחזר את המיקוד רק ל-popover="auto" הראשון במחסנית, כך שרמות מקוננות חייבות לשחזר את המיקוד לטריגר שלהן באופן מפורש.

הטמעת האינטראקציה

הטמיעו את ההתנהגויות שלמעלה בתגי העיצוב שלכם. הטיפול בחץ, Home/End, גלישת טקסט ו-Tab ליציאה הוא קוד keydown רגיל. יש כמה חלקים שקל לטעות בהם, והם מקובצים למטה לפי סוג הקלט.

טיפול במקלדת ובמיקוד

  1. נתיב פתוח אחד. פתיחה באמצעות המקלדת מפעילה את trigger.click(), כך שה-toggle-popover של המפעיל היא הדרך היחידה לפתוח תפריט. מסמנים את התפריט כדי שה-handler של toggle ייכנס אליו, המקלדת נפתחת בלבד. כשפותחים את הסמן, toggle מתמקד בטריגר בלי טבעת, כי pointerdown מנע את המיקוד ומקשי החיצים עדיין צריכים לפעול.
  2. תצוגה מקדימה של המיקוד. התמקדות בטריגר מפעילה את showPopover({ source: trigger }) כדי שהתפריט יעוגן אליו. התעלמות מהמיקוד שחוזר מתוך התפריט של הטריגר עצמו, או מחלון אחר (סימון בחלון blur, ניקוי סימון אחרי חלון focus, או מעבר בין אפליקציות שפותח מחדש את התפריט האחרון). הסתרת תפריט בתצוגה מקדימה ב-blur, אלא אם המיקוד עבר אליו.
  3. העברת המיקוד בtoggle, לא בcommand. האירוע command מופעל לפני שהתפריט נפתח.
  4. החזירו את המיקוד לטריגר בעצמכם. העברת המיקוד לטריגר לפני hidePopover(). אחרי הפעלת הסמן, המיקוד משוחזר בלי טבעת (focus({ focusVisible: false })), או שהטריגר מציג את קווי המתאר של מיקוד המקלדת אחרי לחיצה על העכבר. הפעלות של המקלדת שומרות על הטבעת. קליק עם event.detail === 0 הגיע מהמקלדת.
  5. סוגרים רמה אחת בכל פעם. צריך להפעיל את stopPropagation() ב-Escape וב-ArrowLeft בתפריט משנה, אחרת גם המאזין של ההורה יסגור אותו.

טיפול בסמן

  1. החלפת מצב של לחיצה על הסמן. מבקשים להפעיל את preventDefault() ב-pointerdown כדי שהקליק לא יעביר את המיקוד ויתחיל תצוגה מקדימה באמצע הקליק. זכרו איזה טריגר לחצתם. לחיצה על טריגר סגור או על טריגר בתצוגה מקדימה פותחת או סוגרת אותו באופן מקורי, ולא נכנסת לתפריט. רק פתיחה באמצעות המקלדת נכנסת לתפריט. אל תיירטו את הקליק.
  2. המעבר עם העכבר עוקב אחרי המיקוד. עיצוב נפרד של :hover ו-:focus-visible. ב-pointermove מעל פריט, צריך להפעיל את focus({ focusVisible: false }). דפדפנים שמתעלמים מהאפשרות לא מציירים טבעת למיקוד של הסמן בכל מקרה.
  3. התפריטים נפתחים כשמעבירים מעליהם את העכבר. באירוע pointerenter של טריגר ברמה העליונה בזמן שתפריט אחר ברמה העליונה פתוח: מסתירים אותו, מתמקדים בטריגר שהסמן עבר מעליו בלי טבעת, ובתצוגה המקדימה במיקוד (ראו למעלה) מוצגת תצוגה מקדימה שלו. אין אפליקציות פתוחות: לא לעשות כלום.
  4. משולש בטוח. גשר CSS קבוע הוא צר מדי: הוא פועל רק כשהסמן מתחיל ליד הקצה של רכיב ההורה. אפשר להשתמש ב-CSS כדי לצייר את הגשר, ולהשתמש בסקריפט רק כדי לדווח על הסמן:
    • צריך להריץ את זה רק כשmatchMedia('(hover: hover)').matches; זה לא רלוונטי למגע ולמקלדת.
    • הטיימר מתחיל לפעול כשהסמן יוצא מטריגר של תפריט משנה שתפריט המשנה שלו פתוח. דילוג על תפריט משנה של --below, שאין לו קצה צדדי.
    • מגדירים את --bridge-x ו---bridge-y (הסמן, בפיקסלים ביחס לתפריט המשנה), --bridge-edge (הקצה הקרוב של תפריט המשנה x: 0 כשהסמן נמצא משמאלו, הרוחב שלו כשהוא מתהפך) ו---bridge-height בתפריט המשנה, ואת data-bridge כדי להפעיל את הגשר.
    • ההקראה נפסקת אחרי כ-300 אלפיות השנייה, כשהסמן מגיע לפריט בתפריט המשנה או כשתפריט המשנה נסגר. מסירים את המאזין ואת המאפיין. שום דבר לא פועל אחרי זה.
    • ה-CSS נותן לתפריט המשנה ::before שחתוך למשולש מהמצביע לפינות הקרובות לקצה. הוא נמצא מעל הפריטים האחרים בנתיב של הסמן, ולכן הם אף פעם לא מקבלים את ההעברה שהייתה סוגרת את תפריט המשנה. ‫clip-path גם מגביל את בדיקת מיקום המצביע, כך ששאר התפריט הראשי נשאר אינטראקטיבי.
    • לתפריט המשנה צריך להוסיף את המאפיין overflow: visible (כברירת מחדל, חלון קופץ נחתך), והרכיב המדומה חורג ממנו, אז צריך להזיז את הנקודות של הצורה בהתאם.
    • המשך: נתיב מעוקל (פקודות עקומות של shape()) עוקב אחר קשתות מצביע אמיתיות טוב יותר מאשר משולש ישר.
[popover] [popover] {
  overflow: visible;

  &[data-bridge]::before {
    content: "";
    position: absolute;
    inset: -100vh -100vw; /* Room for the triangle, which can extend far outside the submenu. */
    clip-path: shape(
      from calc(100vw + var(--bridge-x)) calc(100vh + var(--bridge-y)),
      line to calc(100vw + var(--bridge-edge)) 100vh,
      line to calc(100vw + var(--bridge-edge)) calc(100vh + var(--bridge-height)),
      close
    );
  }
}
const canHover = matchMedia('(hover: hover)');

// Call on pointerleave of a submenu trigger, with that trigger's submenu.
function startBridge(submenu, event) {
  if (!canHover.matches || !submenu.matches(':popover-open')) return;
  const rect = submenu.getBoundingClientRect();
  if (event.clientX >= rect.left && event.clientX <= rect.right) return; // --below: no side edge.
  const style = submenu.style;
  style.setProperty('--bridge-edge', `${event.clientX < rect.left ? 0 : rect.width}px`);
  style.setProperty('--bridge-height', `${rect.height}px`);
  const update = ({ clientX, clientY, target }) => {
    if (target !== submenu && submenu.contains(target)) return stop(); // Reached an item.
    style.setProperty('--bridge-x', `${clientX - rect.left}px`);
    style.setProperty('--bridge-y', `${clientY - rect.top}px`);
  };
  const timer = setTimeout(() => stop(), 300);
  const stop = () => {
    clearTimeout(timer);
    document.removeEventListener('pointermove', update);
    submenu.removeEventListener('toggle', stop);
    submenu.removeAttribute('data-bridge');
  };
  update(event);
  submenu.dataset.bridge = '';
  document.addEventListener('pointermove', update);
  submenu.addEventListener('toggle', stop, { once: true });
}

הפונקציה Hover follows focus (פריט 2) נשארת פונקציית טיפול פשוטה ב-pointermove: היא לא צריכה לוגיקה של משולש.

כמו כן:

  • מחוץ לפוקוס. סגירת כל התפריטים הפתוחים כשהמיקוד עובר אל מחוץ למערכת התפריטים (מאזין document focusin).
  • הפעלת פקודות. הכרזה על הפעולה, הסתרת תפריט הבסיס והחזרת המיקוד לטריגר שלו, בלי טבעת אם ההפעלה הגיעה מסמן. לפריטים שניתן לסמן (menuitemcheckbox, menuitemradio), צריך להמתין כ-300 אלפיות השנייה לפני הסגירה כדי שסימן הווי שהשתנה יהיה גלוי. צריך לעצב טריגר סגור כפעיל רק ב-:focus-visible. אחרת, המיקוד המשוחזר (ללא טבעת) משאיר אותו מודגש אחרי בחירה באמצעות סמן.
  • aria-expanded. צריך להגדיר אותו רק במאזין toggle. הוא כולל מצבי תצוגה מקדימה ופתיחה, וה-aria-expanded של הטריגר נשאר "true" בזמן שהתפריט שלו מוצג בתצוגה מקדימה או פתוח.
  • עוזרים. האפשרויות החלופיות הבאות מניחות שאתם מגדירים רכיבי עזר לתגי העיצוב: triggers (כל הלחצנים של [commandfor]), menuFor(trigger), isOpen(menu), closeAndRestore(menu, { fromPointer }) (התמקדות בטריגר, בלי טבעת כש-fromPointer, ואז menu.hidePopover()), enterOnOpen (WeakSet של תפריטים לכניסה אחרי הפתיחה) ו-pointerTrigger (הטריגר האחרון שנלחץ על ידי סמן).
  • משתמשים ב-popover="auto" לתפריטי משנה, וממקמים אותו אחרי הטריגר של תפריט המשנה בתוך ה-focusgroup של ההורה. אף פעם לא popover="manual".
  • ההתאמה מאפשרת לדפדפן להשאיר את תפריט ההורה פתוח, לסגור תפריטי משנה אחים, לסגור תפריטי משנה צאצאים כשתפריט הבסיס נסגר, ולטפל בסגירה קלה בלחיצה מחוץ לתפריט.
  • הטריגר של תפריט המשנה שומר על הסנכרון של aria-haspopup="menu" ו-aria-expanded, והמאזין של toggle שומר על הסנכרון של aria-expanded.
  • הסקריפט של המקלדת מטפל בתגי Escape ו-ArrowLeft בתצוגת עץ כמו שמתואר למעלה.
  • טריגר של תפריט משנה אף פעם לא מפעיל גם פקודה: פקד אחד לא יכול גם להפעיל וגם לפתוח, ו-Enter לא יכול להיות גם וגם. הגדירו את פעולת ברירת המחדל כפריט הראשון בתפריט המשנה, או הוסיפו פריט פקודה נפרד לצד הטריגר. אם נאלצתם להשתמש בפקד משולב, תעדו את זה כפשרה בנושא נגישות.

מפרידים ופריטים שניתן לסמן

  • הפרדת קבוצות פריטים באמצעות <hr> בתוך focusgroup: רכיב separator מקורי, לא ניתן להתמקד בו, focusgroup והחלופה מדלגים עליו. אף פעם לא <div> מעוצב.
  • אפשרות בוליאנית היא <button type="button"> עם aria-checked ו-role="menuitemcheckbox" מפורש. התכונה Focusgroup לא מסיקה את התפקידים האלה, אז זה המקום היחיד שבו תגי עיצוב משותפים מגדירים תפקיד של פריט.
  • אחד מהרכיבים בקבוצה הוא <button type="button"> עם role="menuitemradio" ו-aria-checked, בתוך role="group" עם aria-label, באותה קבוצת מיקוד.
  • הסקריפט מחליף את המצב של aria-checked.
  • Enter בתיבת סימון מחליף את המצב וסוגר את התפריט; Space מחליף את המצב ומשאיר את התפריט פתוח (APG). התחנות נסגרות בכל אחד מהמקשים.
  • מציגים את סימן הווי מ-[aria-checked="true"]::before, ולא רכיב סמל נפרד (ראו את ה-CSS בהמשך).

לא נכלל כאן

  • משוב על ההפעלה. בתפריטים מקוריים, הפריט שנבחר מהבהב לפני שהתפריט נסגר. אם רוצים, אפשר להציג אישור חזותי של כ-100 אלפיות השנייה לפני hidePopover(). אין לעכב את שחזור המיקוד או את ההודעה.
  • השלמה אוטומטית. אין קבוצת מיקוד. אם צריך, ב-keydown שניתן להדפסה, מתאימים את תוויות הפריטים המופעלות ברמה הנוכחית וממקדים את ההתאמה הבאה אחרי הפריט הנוכחי, עם גלישת טקסט. לא ליירט את Space.

אימות לפני סיום

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

  • מקלדת. Tab מפעיל את הטריגר הראשון פעם אחת; ArrowDown נכנס; Escape סוגר רמה אחת ומחזיר את המיקוד לטריגר שלו; Tab סוגר את כל התפריטים.
  • הסמן פתוח. לחיצה על טריגר פותחת את התפריט שלו, כשהמיקוד הוא על הטריגר וללא טבעת. לחיצה נוספת סוגרת אותו.
  • פקודת המצביע. לחיצה על פקודה סוגרת את כל התפריטים ולא מציגה טבעת מיקוד על הטריגר.
  • תפריט משנה. אם תעבירו את הסמן באלכסון מהמרכז של טריגר תפריט המשנה לפריט בתפריט המשנה, הוא יישאר פתוח. אם תעבירו את הסמן לפריט אחים לא קשור, הוא ייסגר.
  • מסך צר. ברוחב של 375 פיקסלים, אף תפריט משנה לא מכסה את הטריגר שלו.
  • סימני וי. פריטים שנבדקו מסומנים בסימן וי.
  • אין שאריות. אין שגיאות במסוף, ואין popovertarget, popovertargetaction או aria-details בטריגרים.

יישור התוכן של פריט בתפריט

כשיש בשורה סמל, תווית וקיצור דרך או אינדיקטור לתפריט משנה, צריך להקצות משבצת ראשית בגודל קבוע לסמל או לסימן הווי, ולהעביר את התוכן שאחריו לסוף השורה. הסמלים הם בגודל ידוע, כך שמשבצת קבועה שומרת על יישור התוויות, בין אם לפריט יש סמל ובין אם לא. אל תשתמשו ב-subgrid לשורות: כל צאצא נוסף בשורה (למשל, תג) הופך לפריט רשת משלו ומשבש את הפריסה. הסתרת טקסט של מקש קיצור מטכנולוגיה מסייעת כשהוא רק חוזר על מקש שכבר מפעיל את הפריט.

[focusgroup~="menu"] {
  --menu-icon-size: 1.25rem;
}

/* Target items only. A closed submenu popover inside the focusgroup must keep display: none. */
[focusgroup~="menu"] > button,
[focusgroup~="menu"] > [role="group"] > button {
  display: flex;
  align-items: center;
  gap: .5rem;
}

/* The leading slot holds the icon or check mark. */
[focusgroup~="menu"] button::before {
  content: "";
  flex: none;
  inline-size: var(--menu-icon-size);
}

/* Check mark for checked items. This selector must be at least as specific as the base
   ::before rule above, or the empty content wins and the check mark never shows. */
[focusgroup~="menu"] button[aria-checked="true"]::before {
  content: "✓";
}

/* Shortcut or submenu indicator at the inline end. */
[focusgroup~="menu"] .hint {
  margin-inline-start: auto;
}

צריך להוסיף אינדיקטור או קיצור דרך בסוף בתוך <span class="hint" aria-hidden="true"> בתוך הכפתור. הטריגר של תפריט המשנה כבר מכריז על החלון הקופץ שלו.

שיפור הדרגתי (progressive enhancement) וחלופות

תמיכה בדפדפנים ובמצבי fallback של focusgroup

משאירים את focusgroup="menu nomemory" בתגי העיצוב. אם הערך של 'focusGroup' in HTMLElement.prototype הוא false, צריך להגדיר את המקשים Up,‏ Down,‏ Home ו-End, להגדיר את role="menu" בכל מאגר תפריטים ואת role="menuitem" בלחצנים ללא תפקיד. אין לשנות פריטים שניתן לסמן, <hr> ו-role="group". אין להתקין אותו כשיש focusgroup מקורי. שמאל, ימין, תצוגה מקדימה, פתיחה וסגירה הם תסריטים של היוצר בכל מקרה.

if (!('focusGroup' in HTMLElement.prototype)) {
  document.querySelectorAll('[focusgroup~="menu"]').forEach(group => {
    group.setAttribute('role', 'menu');
    group.querySelectorAll('button:not([role])').forEach(item => {
      item.setAttribute('role', 'menuitem');
    });
    group.addEventListener('keydown', event => {
      if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
      // Own items only: a nested submenu has its own focusgroup, a role="group" wrapper does not.
      const items = [...group.querySelectorAll('button')]
        .filter(item => item.closest('[focusgroup]') === group)
        .filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
      const current = items.indexOf(document.activeElement);
      if (current < 0) return;
      const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :
        (current + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
      event.preventDefault();
      event.stopImmediatePropagation();
      items[next].focus({ preventScroll: true });
    }, true);
  });
}

תמיכה בדפדפנים וחלופות ל-Popover

‫Popover API הוא בעיקר שיפור הדרגתי (progressive enhancement), אבל להתנהגויות שמגדירות אותו – קידום לשכבה העליונה, סגירה בלחיצה מחוץ לאלמנט והפעלה של popovertarget – אין מקבילה ב-CSS בלבד. בדפדפנים ישנים יותר צריך להשתמש ב-polyfill, או להגדיר חלופה ידנית אם אתם מעדיפים לא להשתמש ב-polyfill.

Polyfill: כדי לתמוך במאפיין popover בדפדפנים ישנים יותר, צריך לטעון את @oddbird/popover-polyfill באופן מותנה. חובה: צריך לזהות את התכונה על ידי בדיקת המאפיין popover ב-HTMLElement.prototype, ולטעון את ה-polyfill רק כשאין תמיכה מקומית – אל תטענו אותו ללא תנאי.

עם bundler או מפת ייבוא:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

בלי כלי לאריזת קוד, מייבאים מ-CDN בתוך <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

אזהרה לגבי סגנון: ה-polyfill לא יכול להגדיר את המחלקה המדומה :popover-open, אז הוא מחיל במקום זאת מחלקה .\:popover-open. חובה: צריך לשלב את שניהם עם :is() או :where(), אחרת דפדפנים שאין בהם :popover-open יתעלמו מהכלל כולו:

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

לחלופין, כדי להשתמש ב-fallback מדור קודם בלי polyfill, אפשר להשתמש ב-position: fixed ולחשב קואורדינטות באופן ידני באמצעות getBoundingClientRect() או להסתמך על מיקום ברירת המחדל עם inset: auto אם זה מתאים לתרחיש לדוגמה.

תמיכה בדפדפנים וחלופות למיקום של עוגנים

בלי מיקום עוגן ב-CSS, חלון קופץ חוזר למיקום ברירת המחדל של סוכן המשתמש (UA) במרכז אזור התצוגה. אל תשתמשו בזה לתפריט: זה מנתק את התפריט מהטריגר שלו, ותפריט משנה ממורכז מכסה את התפריט הראשי. מגבילים את הכללים המעוגנים מאחורי @supports (position-area: block-end) וממקמים את התפריט מהסקריפט כשהוא נכשל:

  • התפריט צריך להיות מתחת לטריגר שלו (ברמה העליונה) או לידו (תפריט משנה), ולהתהפך לצד השני ולהיצמד לאזור התצוגה כשהוא חורג ממנו.
  • מריצים אותה ב-handler של toggle כשהתפריט נפתח, ומחילים את התוצאה בתור left ו-top בתפריט. משתמשים ב-position: fixed עם inset: auto ו-margin: 0 כדי שהקואורדינטות יהיו יחסיות לאזור התצוגה.
  • חשוב להשאיר את ההגדרות האלה בתוך @supports not (position-area: block-end), כדי שדפדפנים עם מיקום עוגן לא יושפעו.

אם אתם מטרגטים הטמעות קודמות שבהן showPopover({ source }) לא יוצר עוגן משתמע (אין source ב-ToggleEvent.prototype), תנו לכל טריגר anchor-name ייחודי ולכל תפריט position-anchor תואם מהסקריפט, במקום לכתוב כללי CSS לכל תפריט.

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

תמיכה בדפדפנים ובמצבי חלופות לפקודות Invoker

בלי מפעילים, command="toggle-popover" לא עושה כלום, אז הסקריפט צריך להחליף את מצב התפריט.

  • זיהוי תכונות של הממשק, לא של מופע: 'commandForElement' in HTMLButtonElement.prototype.
  • בכל טריגר click, צריך להפעיל את preventDefault() כדי שדפדפן עם invokers לא יוכל להחליף מצב פעמיים.
  • אם התפריט פתוח, סוגרים אותו באמצעות closeAndRestore(menu). אחרת, צריך להתקשר אל menu.showPopover({ source: trigger }).
  • צריך לסמן את התפריט ב-enterOnOpen, אלא אם הקליק הגיע מהטריגר שנלחץ (pointerTrigger), כך שרק פתיחות באמצעות המקלדת יפתחו את התפריט.
  • הפקודות Enter ו-Space עדיין פועלות כי הן יוצרות קליק על לחצנים.